From 2fb867fd1bf54ab81502e5105fd6abdd96da7568 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 18 Sep 2026 09:03:43 +0200 Subject: [PATCH 01/25] Add retained typemap key diagnostics Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Documentation/docs-mobile/TOC.yml | 4 +++ Documentation/docs-mobile/messages/index.md | 2 ++ Documentation/docs-mobile/messages/xa4327.md | 32 +++++++++++++++++ Documentation/docs-mobile/messages/xa4328.md | 33 +++++++++++++++++ .../Properties/Resources.Designer.cs | 36 +++++++++++++++++++ .../Properties/Resources.resx | 18 ++++++++++ 6 files changed, 125 insertions(+) create mode 100644 Documentation/docs-mobile/messages/xa4327.md create mode 100644 Documentation/docs-mobile/messages/xa4328.md diff --git a/Documentation/docs-mobile/TOC.yml b/Documentation/docs-mobile/TOC.yml index 6116d95ea22..dfd9f46be5b 100644 --- a/Documentation/docs-mobile/TOC.yml +++ b/Documentation/docs-mobile/TOC.yml @@ -382,6 +382,10 @@ href: messages/xa4325.md - name: XA4326 href: messages/xa4326.md + - name: XA4327 + href: messages/xa4327.md + - name: XA4328 + href: messages/xa4328.md - name: "XA5xxx: GCC and toolchain" items: - name: "XA5xxx: GCC and toolchain" diff --git a/Documentation/docs-mobile/messages/index.md b/Documentation/docs-mobile/messages/index.md index abe2d010109..c869318e78b 100644 --- a/Documentation/docs-mobile/messages/index.md +++ b/Documentation/docs-mobile/messages/index.md @@ -263,6 +263,8 @@ Either change the value in the AndroidManifest.xml to match the $(SupportedOSPla + [XA4324](xa4324.md): [{arch}] Unable to delete source file '{file}'. + [XA4325](xa4325.md): Failed to rewrite managed JNI names for R8. {message} + [XA4326](xa4326.md): Unable to safely rewrite a JNI member lookup because its class handle does not have one structurally unambiguous `JNIEnv.FindClass` source. ++ [XA4327](xa4327.md): Could not extract Java type map keys from '{0}': {1} ++ [XA4328](xa4328.md): Could not generate typemap ProGuard configuration from '{0}': {1} ## XA5xxx: GCC and toolchain diff --git a/Documentation/docs-mobile/messages/xa4327.md b/Documentation/docs-mobile/messages/xa4327.md new file mode 100644 index 00000000000..4c67601e0f3 --- /dev/null +++ b/Documentation/docs-mobile/messages/xa4327.md @@ -0,0 +1,32 @@ +--- +title: .NET for Android error XA4327 +description: XA4327 error code +ms.date: 09/18/2026 +f1_keywords: + - "XA4327" +--- + +# .NET for Android error XA4327 + +## Example message + +``` +error XA4327: Could not extract Java type map keys from 'typemaps.arm64-v8a.ll': ... +``` + +## Issue + +The build could not read retained Java class names from a NativeAOT dependency +graph and ACW map, a CoreCLR LLVM typemap, or linked trimmable typemap assemblies. +The input is missing, unreadable, malformed, or uses an unsupported representation. +The diagnostic includes the input and the underlying failure. + +An empty, valid typemap is supported. A failed extraction is not treated as an +empty typemap, because doing so could remove Java classes required at runtime. + +## Solution + +Fix the reported input or the earlier task that produces it. If the input was +generated by .NET for Android, clean and rebuild the application. If the error +persists, [report an issue](https://github.com/dotnet/android/issues/new/choose) +with a binary build log and the affected typemap input. diff --git a/Documentation/docs-mobile/messages/xa4328.md b/Documentation/docs-mobile/messages/xa4328.md new file mode 100644 index 00000000000..47600405eef --- /dev/null +++ b/Documentation/docs-mobile/messages/xa4328.md @@ -0,0 +1,33 @@ +--- +title: .NET for Android error XA4328 +description: XA4328 error code +ms.date: 09/18/2026 +f1_keywords: + - "XA4328" +--- + +# .NET for Android error XA4328 + +## Example message + +``` +error XA4328: Could not generate typemap ProGuard configuration from 'typemap.keys.txt': Line 1 contains an invalid retained Java class name: 'example/*'. +``` + +## Issue + +The class-only ProGuard generator could not read a retained-keys file, found +an invalid class name, received no input files, or could not write its output. +Keys must be UTF-8 Java class names using `/` as the package separator, such as +`example/Outer$Inner`. Wildcards, ProGuard directives, dotted names, type +descriptors, and typemap alias suffixes are not valid records. + +A zero-byte input is valid and produces no class keep rules. A missing file is +an error, not an empty input. + +## Solution + +Fix the reported input or output path and permissions. For files generated by +.NET for Android, clean and rebuild. If the error persists, +[report an issue](https://github.com/dotnet/android/issues/new/choose) with a +binary build log and the affected keys file. diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs index 13986a46813..0da0ec3a3b4 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs @@ -2031,6 +2031,42 @@ public static string XA4326 { } } + /// + /// Looks up a localized string similar to Could not extract Java type map keys from '{0}': {1}. + /// + public static string XA4327 { + get { + return ResourceManager.GetString("XA4327", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not generate typemap ProGuard configuration from '{0}': {1}. + /// + public static string XA4328 { + get { + return ResourceManager.GetString("XA4328", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No retained Java type map key files were provided.. + /// + public static string XA4328_NoInputs { + get { + return ResourceManager.GetString("XA4328_NoInputs", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Line {0} contains an invalid retained Java class name: '{1}'.. + /// + public static string XA4328_InvalidName { + get { + return ResourceManager.GetString("XA4328_InvalidName", resourceCulture); + } + } + /// /// Looks up a localized string similar to Missing Android NDK toolchains directory '{0}'. Please install the Android NDK.. /// diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx index c85742d1345..1a109688e4c 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx @@ -901,6 +901,24 @@ Remove the '{0}' reference from your project and add the '{1}' NuGet package ins Unable to safely rewrite a JNI member lookup because its class handle does not have one structurally unambiguous JNIEnv.FindClass source. The following are literal API names and should not be translated: JNI, JNIEnv.FindClass. + + Could not extract Java type map keys from '{0}': {1} + {0} - The input file path or representation name. +{1} - The underlying parser or input/output failure. It is not localized. + + + Could not generate typemap ProGuard configuration from '{0}': {1} + {0} - The input or output file path. +{1} - The underlying failure, or a formatted XA4328_* resource. + + + No retained Java type map key files were provided. + + + Line {0} contains an invalid retained Java class name: '{1}'. + {0} - The one-based line number. +{1} - The invalid input record. + Missing Android NDK toolchains directory '{0}'. Please install the Android NDK. {0} - The path of the missing directory From 14cc46002315eeaae61f1314f07c1a551a62bf45 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 18 Sep 2026 09:04:50 +0200 Subject: [PATCH 02/25] Generate class-only ProGuard rules from retained typemap keys Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../GenerateTypeMapProguardConfiguration.cs | 98 +++++++++++++++ ...nerateTypeMapProguardConfigurationTests.cs | 114 ++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 src/Xamarin.Android.Build.Tasks/Tasks/GenerateTypeMapProguardConfiguration.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTypeMapProguardConfigurationTests.cs diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTypeMapProguardConfiguration.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTypeMapProguardConfiguration.cs new file mode 100644 index 00000000000..594091af35b --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTypeMapProguardConfiguration.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using Microsoft.Android.Build.Tasks; +using Microsoft.Build.Framework; + +namespace Xamarin.Android.Tasks; + +public class GenerateTypeMapProguardConfiguration : AndroidTask +{ + public override string TaskPrefix => "GTMPC"; + + [Required] + public ITaskItem [] TypeMapKeyFiles { get; set; } = []; + + [Required] + public string OutputFile { get; set; } = ""; + + public override bool RunTask () + { + var classes = new SortedSet (StringComparer.Ordinal); + string currentFile = ""; + try { + if (TypeMapKeyFiles.Length == 0) { + Log.LogCodedError ("XA4328", Properties.Resources.XA4328, "", Properties.Resources.XA4328_NoInputs); + return false; + } + + foreach (var file in TypeMapKeyFiles) { + currentFile = file.ItemSpec; + using var reader = new StreamReader (currentFile, new UTF8Encoding (false, true), detectEncodingFromByteOrderMarks: false); + string? name; + int lineNumber = 0; + while ((name = reader.ReadLine ()) != null) { + lineNumber++; + if (string.IsNullOrWhiteSpace (name)) { + continue; + } + if (!IsClassName (name)) { + Log.LogCodedError ("XA4328", Properties.Resources.XA4328, currentFile, + string.Format (Properties.Resources.XA4328_InvalidName, lineNumber, name)); + return false; + } + classes.Add (name.Replace ('/', '.')); + } + } + + currentFile = OutputFile; + var directory = Path.GetDirectoryName (OutputFile); + if (!directory.IsNullOrEmpty ()) { + Directory.CreateDirectory (directory); + } + using var writer = new StreamWriter (OutputFile, append: false, new UTF8Encoding (false)) { NewLine = "\n" }; + foreach (var name in classes) { + writer.WriteLine ($"-keep class {name}"); + } + } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is DecoderFallbackException) { + Log.LogCodedError ("XA4328", Properties.Resources.XA4328, currentFile, ex.Message); + return false; + } + return !Log.HasLoggedErrors; + } + + static bool IsClassName (string name) + { + bool first = true; + for (int i = 0; i < name.Length; i++) { + if (name [i] == '/') { + if (first) { + return false; + } + first = true; + continue; + } + + var category = CharUnicodeInfo.GetUnicodeCategory (name, i); + bool start = category == UnicodeCategory.UppercaseLetter || + category == UnicodeCategory.LowercaseLetter || + category == UnicodeCategory.TitlecaseLetter || + category == UnicodeCategory.ModifierLetter || + category == UnicodeCategory.OtherLetter || + category == UnicodeCategory.LetterNumber || + category == UnicodeCategory.CurrencySymbol || + category == UnicodeCategory.ConnectorPunctuation; + if (!start && (first || (category != UnicodeCategory.DecimalDigitNumber && + category != UnicodeCategory.NonSpacingMark && category != UnicodeCategory.SpacingCombiningMark))) { + return false; + } + if (char.IsHighSurrogate (name [i])) { + i++; + } + first = false; + } + return !first; + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTypeMapProguardConfigurationTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTypeMapProguardConfigurationTests.cs new file mode 100644 index 00000000000..579ffcd30e6 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTypeMapProguardConfigurationTests.cs @@ -0,0 +1,114 @@ +using System; +using System.IO; +using System.Text; +using Microsoft.Build.Utilities; +using NUnit.Framework; +using Xamarin.Android.Tasks; + +namespace Xamarin.Android.Build.Tests; + +[TestFixture] +[Parallelizable (ParallelScope.Children)] +public class GenerateTypeMapProguardConfigurationTests : BaseTest +{ + [Test] + public void UnionsClassNamesWithoutMemberOrGlobalRules () + { + var path = Path.Combine (Root, "temp", TestName); + Directory.CreateDirectory (path); + var first = Path.Combine (path, "first.keys"); + var second = Path.Combine (path, "second.keys"); + File.WriteAllText (first, "test/Outer$Inner\r\nandroid/app/Activity\r\n\r\ntest/Caf\u00e9\n", new UTF8Encoding (false)); + File.WriteAllText (second, "test/Outer$Inner\nandroid/app/Activity\ntest/\U00010428Peer\n", new UTF8Encoding (false)); + var task = new GenerateTypeMapProguardConfiguration { + BuildEngine = new MockBuildEngine (TestContext.Out), + TypeMapKeyFiles = [new TaskItem (second), new TaskItem (first)], + OutputFile = Path.Combine (path, "proguard", "classes.cfg"), + }; + + Assert.IsTrue (task.Execute ()); + var expected = "-keep class android.app.Activity\n-keep class test.Caf\u00e9\n-keep class test.Outer$Inner\n-keep class test.\U00010428Peer\n"; + CollectionAssert.AreEqual (new UTF8Encoding (false).GetBytes (expected), File.ReadAllBytes (task.OutputFile)); + } + + [TestCase ("test/*")] + [TestCase ("test/Foo { *; }")] + [TestCase ("test/Foo[1]")] + [TestCase ("test.Foo")] + [TestCase ("test//Foo")] + [TestCase ("/test/Foo")] + [TestCase ("test/Foo/")] + [TestCase ("test/1Foo")] + [TestCase ("test/Foo;")] + [TestCase ("test/Foo #comment")] + [TestCase ("-dontshrink")] + [TestCase ("test/Foo\n-keep class **")] + [TestCase ("test/Foo\r-dontobfuscate")] + [TestCase ("\ufefftest/Foo")] + [TestCase ("test/Foo\u0000")] + public void RejectsInvalidRecordsWithoutOverwritingOutput (string content) + { + var path = Path.Combine (Root, "temp", TestName); + Directory.CreateDirectory (path); + var input = Path.Combine (path, "input.keys"); + var output = Path.Combine (path, "classes.cfg"); + File.WriteAllText (input, content, new UTF8Encoding (false)); + File.WriteAllText (output, "previous output"); + var task = CreateTask (input, output); + + Assert.IsFalse (task.Execute ()); + Assert.AreEqual ("previous output", File.ReadAllText (output)); + } + + [Test] + public void EmptyInputOverwritesOutputAndRefreshesTimestamp () + { + var path = Path.Combine (Root, "temp", TestName); + Directory.CreateDirectory (path); + var input = Path.Combine (path, "input.keys"); + var output = Path.Combine (path, "classes.cfg"); + File.WriteAllText (input, ""); + File.WriteAllText (output, "stale"); + var old = DateTime.UtcNow.AddDays (-1); + File.SetLastWriteTimeUtc (output, old); + var task = CreateTask (input, output); + + Assert.IsTrue (task.Execute ()); + Assert.AreEqual (0, new FileInfo (output).Length); + Assert.Greater (File.GetLastWriteTimeUtc (output), old); + File.SetLastWriteTimeUtc (output, old); + Assert.IsTrue (task.Execute ()); + Assert.Greater (File.GetLastWriteTimeUtc (output), old, "Unchanged output must still be a real incremental output."); + File.Delete (output); + Assert.IsTrue (task.Execute ()); + FileAssert.Exists (output); + } + + [Test] + public void MissingInputIsNotAnEmptyMap () + { + var path = Path.Combine (Root, "temp", TestName); + Directory.CreateDirectory (path); + var task = CreateTask (Path.Combine (path, "missing.keys"), Path.Combine (path, "classes.cfg")); + Assert.IsFalse (task.Execute ()); + Assert.IsFalse (File.Exists (task.OutputFile)); + } + + [Test] + public void RejectsInvalidUtf8 () + { + var path = Path.Combine (Root, "temp", TestName); + Directory.CreateDirectory (path); + var input = Path.Combine (path, "input.keys"); + File.WriteAllBytes (input, [0xff, 0xfe]); + var task = CreateTask (input, Path.Combine (path, "classes.cfg")); + Assert.IsFalse (task.Execute ()); + Assert.IsFalse (File.Exists (task.OutputFile)); + } + + static GenerateTypeMapProguardConfiguration CreateTask (string input, string output) => new () { + BuildEngine = new MockBuildEngine (TestContext.Out), + TypeMapKeyFiles = [new TaskItem (input)], + OutputFile = output, + }; +} From 3e666181a3a9f846fbc7764d51921fabd71b59b0 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 18 Sep 2026 09:07:06 +0200 Subject: [PATCH 03/25] Make shared typemap generator standalone-test compatible Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tasks/GenerateTypeMapProguardConfiguration.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTypeMapProguardConfiguration.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTypeMapProguardConfiguration.cs index 594091af35b..bb4f0a3add2 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTypeMapProguardConfiguration.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTypeMapProguardConfiguration.cs @@ -40,7 +40,7 @@ public override bool RunTask () } if (!IsClassName (name)) { Log.LogCodedError ("XA4328", Properties.Resources.XA4328, currentFile, - string.Format (Properties.Resources.XA4328_InvalidName, lineNumber, name)); + string.Format (CultureInfo.CurrentCulture, Properties.Resources.XA4328_InvalidName, lineNumber, name)); return false; } classes.Add (name.Replace ('/', '.')); @@ -49,7 +49,7 @@ public override bool RunTask () currentFile = OutputFile; var directory = Path.GetDirectoryName (OutputFile); - if (!directory.IsNullOrEmpty ()) { + if (directory != null && directory.Length > 0) { Directory.CreateDirectory (directory); } using var writer = new StreamWriter (OutputFile, append: false, new UTF8Encoding (false)) { NewLine = "\n" }; From a3d06fe86d722bedcee48a44d8ebdbc02ae0f0fb Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 18 Sep 2026 09:07:00 +0200 Subject: [PATCH 04/25] Extract retained Java type map keys from NativeAOT DGML Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tasks/ExtractTypeMapKeysFromDgml.cs | 200 +++++++++ .../ExtractTypeMapKeysFromDgmlTests.cs | 400 ++++++++++++++++++ 2 files changed, 600 insertions(+) create mode 100644 src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromDgml.cs create mode 100644 tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromDgmlTests.cs diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromDgml.cs b/src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromDgml.cs new file mode 100644 index 00000000000..84f9073a1c4 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromDgml.cs @@ -0,0 +1,200 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using System.Xml; +using Microsoft.Build.Framework; +using Microsoft.Android.Build.Tasks; + +namespace Xamarin.Android.Tasks; + +public class ExtractTypeMapKeysFromDgml : AndroidTask +{ + const string DgmlNamespace = "http://schemas.microsoft.com/vs/2009/dgml"; + const string TypeMetadataPrefix = "Type metadata: ["; + + public override string TaskPrefix => "ETMKD"; + + public ITaskItem [] NativeAotDgmlFiles { get; set; } = []; + + [Required] + public string AcwMapFile { get; set; } = ""; + + public bool TrimJavaCallableWrappers { get; set; } = true; + + [Required] + public string OutputFile { get; set; } = ""; + + public override bool RunTask () + { + if (!File.Exists (AcwMapFile)) { + Log.LogCodedError ("XA4320", Properties.Resources.XA4320, AcwMapFile); + return false; + } + + HashSet? retainedTypeKeys = null; + if (TrimJavaCallableWrappers) { + if (NativeAotDgmlFiles.Length == 0) { + Log.LogCodedError ("XA4319", Properties.Resources.XA4319); + return false; + } + retainedTypeKeys = new HashSet (StringComparer.Ordinal); + foreach (var dgmlFile in NativeAotDgmlFiles) { + if (!File.Exists (dgmlFile.ItemSpec)) { + Log.LogCodedError ("XA4321", Properties.Resources.XA4321, dgmlFile.ItemSpec); + return false; + } + try { + LoadRetainedTypeKeysFromDgml (dgmlFile.ItemSpec, retainedTypeKeys); + } catch (Exception e) when (e is XmlException || e is IOException || e is UnauthorizedAccessException) { + Log.LogCodedError ("XA4327", Properties.Resources.XA4327, dgmlFile.ItemSpec, e.Message); + return false; + } + } + } + + SortedSet javaTypes; + try { + javaTypes = LoadJavaTypesFromAcwMap (retainedTypeKeys); + } catch (Exception e) when (e is FormatException || e is IOException || e is UnauthorizedAccessException || e is DecoderFallbackException) { + Log.LogCodedError ("XA4327", Properties.Resources.XA4327, AcwMapFile, e.Message); + return false; + } + + var directory = Path.GetDirectoryName (OutputFile); + if (!directory.IsNullOrEmpty ()) { + Directory.CreateDirectory (directory); + } + using (var writer = new StreamWriter (OutputFile, false, new UTF8Encoding (false, true)) { NewLine = "\n" }) { + foreach (var javaType in javaTypes) { + writer.WriteLine (javaType); + } + } + + Log.LogMessage (MessageImportance.Low, "Extracted {0} retained Java type map keys from the NativeAOT ACW map.", javaTypes.Count); + return !Log.HasLoggedErrors; + } + + SortedSet LoadJavaTypesFromAcwMap (HashSet? retainedTypeKeys) + { + var javaTypes = new SortedSet (StringComparer.Ordinal); + int lineNumber = 0; + foreach (var line in File.ReadLines (AcwMapFile, new UTF8Encoding (true, true))) { + lineNumber++; + if (line.Length == 0) { + continue; + } + var separator = line.IndexOf (';'); + if (separator <= 0 || separator == line.Length - 1) { + throw new FormatException ($"ACW map line {lineNumber} must contain a managed key and a Java class name separated by ';'."); + } + + var managedTypeName = line.Substring (0, separator); + var javaTypeName = line.Substring (separator + 1).Replace ('.', '/'); + if (managedTypeName.IsNullOrWhiteSpace () || !IsJavaClassName (javaTypeName)) { + throw new FormatException ($"ACW map line {lineNumber} contains an invalid managed key or Java class name."); + } + + // Null means Java trimming is disabled, so even the unqualified ACW entries are retained. + if (retainedTypeKeys == null || retainedTypeKeys.Contains (managedTypeName)) { + javaTypes.Add (javaTypeName); + } + } + return javaTypes; + } + + static bool IsJavaClassName (string name) + { + bool segmentStart = true; + for (int i = 0; i < name.Length; i++) { + if (name [i] == '/') { + if (segmentStart) { + return false; + } + segmentStart = true; + continue; + } + + var category = CharUnicodeInfo.GetUnicodeCategory (name, i); + switch (category) { + case UnicodeCategory.UppercaseLetter: + case UnicodeCategory.LowercaseLetter: + case UnicodeCategory.TitlecaseLetter: + case UnicodeCategory.ModifierLetter: + case UnicodeCategory.OtherLetter: + case UnicodeCategory.CurrencySymbol: + case UnicodeCategory.ConnectorPunctuation: + break; + case UnicodeCategory.DecimalDigitNumber: + case UnicodeCategory.NonSpacingMark: + case UnicodeCategory.SpacingCombiningMark: + case UnicodeCategory.Format: + if (segmentStart) { + return false; + } + break; + default: + return false; + } + + if (char.IsHighSurrogate (name [i])) { + i++; + } + segmentStart = false; + } + return !segmentStart; + } + + static void LoadRetainedTypeKeysFromDgml (string path, HashSet typeKeys) + { + using var reader = XmlReader.Create (path, new XmlReaderSettings { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + }); + + if (reader.MoveToContent () != XmlNodeType.Element || reader.LocalName != "DirectedGraph" || + (reader.NamespaceURI.Length != 0 && reader.NamespaceURI != DgmlNamespace)) { + throw new XmlException ("Expected a DGML DirectedGraph document."); + } + + string graphNamespace = reader.NamespaceURI; + bool readingNodes = false; + // Read through EOF, not just , so a truncated graph never looks like a valid retained set. + while (reader.Read ()) { + if (reader.NamespaceURI != graphNamespace) { + continue; + } + if (reader.Depth == 1 && reader.LocalName == "Nodes") { + readingNodes = reader.NodeType == XmlNodeType.Element && !reader.IsEmptyElement; + continue; + } + if (!readingNodes || reader.Depth != 2 || reader.NodeType != XmlNodeType.Element || reader.LocalName != "Node") { + continue; + } + + var label = reader.GetAttribute ("Label"); + if (label.IsNullOrEmpty () || !label.StartsWith ("Type metadata:", StringComparison.Ordinal)) { + continue; + } + + if (!label.StartsWith (TypeMetadataPrefix, StringComparison.Ordinal)) { + throw new XmlException ($"Invalid NativeAOT type metadata label '{label}'."); + } + var assemblyStart = TypeMetadataPrefix.Length; + var assemblyEnd = label.IndexOf (']', assemblyStart); + if (assemblyEnd <= assemblyStart || assemblyEnd == label.Length - 1) { + throw new XmlException ($"Invalid NativeAOT type metadata label '{label}'."); + } + + var assemblyName = label.Substring (assemblyStart, assemblyEnd - assemblyStart); + var managedTypeName = label.Substring (assemblyEnd + 1); + if (assemblyName.IsNullOrWhiteSpace () || managedTypeName.IsNullOrWhiteSpace ()) { + throw new XmlException ($"Invalid NativeAOT type metadata label '{label}'."); + } + typeKeys.Add ($"{managedTypeName}, {assemblyName}"); + } + } +} diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromDgmlTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromDgmlTests.cs new file mode 100644 index 00000000000..83c4829f2c8 --- /dev/null +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromDgmlTests.cs @@ -0,0 +1,400 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Xamarin.Android.Tasks; +using Xunit; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap.Tests; + +public class ExtractTypeMapKeysFromDgmlTests : IDisposable +{ + readonly string directory = Path.Combine (Path.GetTempPath (), nameof (ExtractTypeMapKeysFromDgmlTests), Guid.NewGuid ().ToString ("N")); + + const string RetainedGraph = """ + + + + + + """; + + const string EmptyGraph = """ + + + + """; + + [Fact] + public void Execute_UnionsGraphsAndMatchesOnlyQualifiedMetadata () + { + var (task, errors) = CreateTask (""" + UnnamedProject.MainActivity, UnnamedProject;crc64a1.MainActivity + Android.App.Activity, Mono.Android;android.app.Activity + Duplicate.Type, My.Assembly;my.app.Duplicate + AndroidX.Activity.Result.Contract.ActivityResultContracts+TakePicture, Xamarin.AndroidX.Activity;androidx.activity.result.contract.ActivityResultContracts$TakePicture + Duplicate.Type;wrong.Unqualified + Duplicate.Type, Other.Assembly;wrong.Assembly + Removed.Type, My.Assembly;removed.Type + Links.Type, My.Assembly;wrong.Links + Nested.Type, My.Assembly;wrong.NestedNode + Foreign.Type, My.Assembly;wrong.ForeignNamespace + Second.Type, My.Assembly;second.Type + Second.Type, My.Assembly;second.Type + Second.Type, My.Assembly;second/Type + """, true, """ + + + + + + + + + + + + + + + + + + + + + """, """ + + + + + + + + """); + + Assert.True (task.Execute ()); + Assert.Empty (errors); + const string expected = "android/app/Activity\n" + + "androidx/activity/result/contract/ActivityResultContracts$TakePicture\n" + + "crc64a1/MainActivity\n" + + "my/app/Duplicate\n" + + "second/Type\n"; + AssertOutput (task, expected); + + Array.Reverse (task.NativeAotDgmlFiles); + task.NativeAotDgmlFiles = task.NativeAotDgmlFiles.Concat (task.NativeAotDgmlFiles).ToArray (); + var mapLines = File.ReadAllLines (task.AcwMapFile); + Array.Reverse (mapLines); + File.WriteAllLines (task.AcwMapFile, mapLines); + Assert.True (task.Execute (), "Input ordering and duplicated graphs must not change the retained set."); + AssertOutput (task, expected); + } + + [Theory] + [InlineData ("absent")] + [InlineData ("missing")] + [InlineData ("malformed")] + public void Execute_KeepsAllMappedClassesWithoutReadingGraphsWhenTrimmingDisabled (string graph) + { + var (task, errors) = CreateTask (""" + Example.Type, App;example.Type + Example.Type;example.Type + example.Type;example.Type + Other.Type;other.Type$Nested + """, false); + if (graph == "missing") { + task.NativeAotDgmlFiles = [new TaskItem (Path.Combine (directory, "missing.dgml.xml"))]; + } else if (graph == "malformed") { + var path = Path.ChangeExtension (task.AcwMapFile, ".dgml.xml"); + File.WriteAllText (path, "not XML"); + task.NativeAotDgmlFiles = [new TaskItem (path)]; + } + + Assert.True (task.Execute ()); + Assert.Empty (errors); + AssertOutput (task, "example/Type\nother/Type$Nested\n"); + } + + [Fact] + public void Execute_PreservesUnicodeAndWritesExactOrdinalUtf8Bytes () + { + const string map = "Example.Type, App;com.\u00e9xample.Peer\u0394$Nested\n" + + "Example.Type, App;com.example.\U00010400Peer\n" + + "Example.Type, App;com.example.A\u0301\n" + + "Example.Type, App;com.example.a\n" + + "Example.Type, App;com.example.Z\n"; + var (task, errors) = CreateTask (map, true, RetainedGraph); + File.WriteAllText (task.AcwMapFile, map, new UTF8Encoding (true)); + + Assert.True (task.Execute ()); + Assert.Empty (errors); + AssertOutput (task, "com/example/A\u0301\ncom/example/Z\ncom/example/a\n" + + "com/example/\U00010400Peer\ncom/\u00e9xample/Peer\u0394$Nested\n"); + } + + [Theory] + [InlineData (true)] + [InlineData (false)] + public void Execute_EmptyMapProducesZeroByteFile (bool trim) + { + var (task, errors) = CreateTask ("", trim, RetainedGraph); + + Assert.True (task.Execute ()); + Assert.Empty (errors); + AssertOutput (task, ""); + } + + [Theory] + [InlineData (EmptyGraph)] + [InlineData ("")] + [InlineData ("")] + public void Execute_ValidGraphWithNoRetainedTypesProducesZeroByteFile (string graph) + { + var (task, errors) = CreateTask ("Example.Type, App;example.Type", true, graph); + + Assert.True (task.Execute ()); + Assert.Empty (errors); + AssertOutput (task, ""); + } + + [Fact] + public void Execute_AcceptsGraphWithoutNamespace () + { + var (task, errors) = CreateTask ("Example.Type, App;example.Type", true, + ""); + + Assert.True (task.Execute ()); + Assert.Empty (errors); + AssertOutput (task, "example/Type\n"); + } + + [Fact] + public void Execute_EmptyNodesDoNotIncludeNodesFromLinks () + { + var (task, errors) = CreateTask ("Example.Type, App;example.Type", true, """ + + + + + + + """); + + Assert.True (task.Execute ()); + Assert.Empty (errors); + AssertOutput (task, ""); + } + + [Fact] + public void Execute_RewritesOutputEvenWhenUnchangedAndTruncatesAnEmptySet () + { + var (task, errors) = CreateTask ("Example.Type, App;example.Type", true, RetainedGraph); + Assert.True (task.Execute ()); + AssertOutput (task, "example/Type\n"); + + var oldTime = new DateTime (2000, 1, 1, 0, 0, 0, DateTimeKind.Utc); + File.SetLastWriteTimeUtc (task.OutputFile, oldTime); + Assert.True (task.Execute ()); + Assert.True (File.GetLastWriteTimeUtc (task.OutputFile) > oldTime, "Output is an MSBuild incremental sentinel."); + AssertOutput (task, "example/Type\n"); + + File.WriteAllText (task.NativeAotDgmlFiles [0].ItemSpec, EmptyGraph); + Assert.True (task.Execute ()); + Assert.Empty (errors); + AssertOutput (task, ""); + } + + [Theory] + [InlineData ("map", "XA4320")] + [InlineData ("graphs", "XA4319")] + [InlineData ("first-graph", "XA4321")] + [InlineData ("second-graph", "XA4321")] + public void Execute_ReportsMissingRequiredInput (string missing, string expectedCode) + { + var (task, errors) = CreateTask ("Example.Type, App;example.Type", true, RetainedGraph, RetainedGraph); + switch (missing) { + case "map": + File.Delete (task.AcwMapFile); + break; + case "graphs": + task.NativeAotDgmlFiles = []; + break; + case "first-graph": + File.Delete (task.NativeAotDgmlFiles [0].ItemSpec); + break; + case "second-graph": + File.Delete (task.NativeAotDgmlFiles [1].ItemSpec); + break; + } + + Assert.False (task.Execute ()); + Assert.Equal (expectedCode, Assert.Single (errors).Code); + Assert.False (File.Exists (task.OutputFile)); + } + + [Fact] + public void Execute_RequiresMapWhenTrimmingDisabled () + { + var (task, errors) = CreateTask ("", false); + File.Delete (task.AcwMapFile); + + Assert.False (task.Execute ()); + Assert.Equal ("XA4320", Assert.Single (errors).Code); + Assert.False (File.Exists (task.OutputFile)); + } + + public static IEnumerable InvalidGraphs () + { + yield return [""]; + yield return ["not XML"]; + yield return [""]; + yield return [EmptyGraph + "<"]; + yield return [""]; + yield return [""]; + yield return [""" + ]> + + """]; + yield return [""" + + + """]; + } + + [Theory] + [MemberData (nameof (InvalidGraphs))] + public void Execute_RejectsMalformedOrUnsupportedGraph (string graph) + { + var (task, errors) = CreateTask ("Example.Type, App;example.Type", true, RetainedGraph, graph); + + Assert.False (task.Execute ()); + Assert.Equal ("XA4327", Assert.Single (errors).Code); + Assert.Contains (task.NativeAotDgmlFiles [1].ItemSpec, errors [0].Message); + Assert.False (File.Exists (task.OutputFile), "A valid first graph must not result in partial output."); + } + + [Theory] + [InlineData ("Type metadata: [App")] + [InlineData ("Type metadata: []Example.Type")] + [InlineData ("Type metadata: [App]")] + [InlineData ("Type metadata: [ ]Example.Type")] + [InlineData ("Type metadata: [App] ")] + [InlineData ("Type metadata: Example.Type")] + public void Execute_RejectsMalformedTypeMetadata (string label) + { + var (task, errors) = CreateTask ("Example.Type, App;example.Type", true, + $""); + + Assert.False (task.Execute ()); + Assert.Equal ("XA4327", Assert.Single (errors).Code); + Assert.False (File.Exists (task.OutputFile)); + } + + [Fact] + public void Execute_ProhibitsLocalExternalEntities () + { + var (task, errors) = CreateTask ("Example.Type, App;example.Type", true, RetainedGraph); + var externalFile = Path.ChangeExtension (task.AcwMapFile, ".external.xml"); + const string externalContent = "external-content-must-not-be-read"; + File.WriteAllText (externalFile, externalContent); + File.WriteAllText (task.NativeAotDgmlFiles [0].ItemSpec, $""" + ]> + &external; + """); + + Assert.False (task.Execute ()); + Assert.Equal ("XA4327", Assert.Single (errors).Code); + Assert.DoesNotContain (externalContent, errors [0].Message); + Assert.False (File.Exists (task.OutputFile)); + } + + [Theory] + [InlineData ("missing separator")] + [InlineData (";example.Type")] + [InlineData (" ;example.Type")] + [InlineData ("Example.Type, App;")] + [InlineData ("Example.Type, App;example.Type;extra")] + [InlineData ("Example.Type, App;example.Type[1]")] + [InlineData ("Example.Type, App;example.Type[]")] + [InlineData ("Example.Type, App;.example.Type")] + [InlineData ("Example.Type, App;example..Type")] + [InlineData ("Example.Type, App;example/Type/")] + [InlineData ("Example.Type, App;example. Type")] + [InlineData ("Example.Type, App;example.*")] + [InlineData ("Example.Type, App;-keep class example.Type")] + public void Execute_RejectsMalformedMapEntries (string map) + { + var (task, errors) = CreateTask (map, true, EmptyGraph); + + Assert.False (task.Execute (), "Invalid map data must fail even if the graph retains no types."); + Assert.Equal ("XA4327", Assert.Single (errors).Code); + Assert.Contains (task.AcwMapFile, errors [0].Message); + Assert.False (File.Exists (task.OutputFile)); + } + + [Fact] + public void Execute_RejectsNumericAliasesWhenTrimmingDisabled () + { + var (task, errors) = CreateTask ("Example.Type;example.Type[1]", false); + + Assert.False (task.Execute ()); + Assert.Equal ("XA4327", Assert.Single (errors).Code); + Assert.False (File.Exists (task.OutputFile)); + } + + [Theory] + [InlineData (false)] + [InlineData (true)] + public void Execute_RejectsInvalidMapEncoding (bool byteOrderMark) + { + var (task, errors) = CreateTask ("", false); + File.WriteAllBytes (task.AcwMapFile, byteOrderMark ? [0xef, 0xbb, 0xbf, 0xc3, 0x28] : [0xc3, 0x28]); + + Assert.False (task.Execute ()); + Assert.Equal ("XA4327", Assert.Single (errors).Code); + Assert.False (File.Exists (task.OutputFile)); + } + + (ExtractTypeMapKeysFromDgml Task, List Errors) CreateTask (string map, bool trim, params string [] graphs) + { + Directory.CreateDirectory (directory); + var acwMapFile = Path.Combine (directory, "acw-map.txt"); + File.WriteAllText (acwMapFile, map); + var graphItems = new List (); + for (int i = 0; i < graphs.Length; i++) { + var path = Path.Combine (directory, $"{i}.dgml.xml"); + File.WriteAllText (path, graphs [i]); + graphItems.Add (new TaskItem (path)); + } + var outputFile = Path.Combine (directory, "keys", "retained-java-types.txt"); + if (File.Exists (outputFile)) { + File.Delete (outputFile); + } + var engine = new TypeMapTaskBuildEngine (); + return (new ExtractTypeMapKeysFromDgml { + BuildEngine = engine, + NativeAotDgmlFiles = graphItems.ToArray (), + AcwMapFile = acwMapFile, + TrimJavaCallableWrappers = trim, + OutputFile = outputFile, + }, engine.Errors); + } + + static void AssertOutput (ExtractTypeMapKeysFromDgml task, string expected) + { + Assert.True (File.Exists (task.OutputFile), "Successful extraction must create a real output file."); + Assert.Equal (new UTF8Encoding (false, true).GetBytes (expected), File.ReadAllBytes (task.OutputFile)); + } + + public void Dispose () + { + if (Directory.Exists (directory)) { + Directory.Delete (directory, recursive: true); + } + } +} From 7ad964808695d363a19c68a23e9527dd7baba2da Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 18 Sep 2026 09:07:09 +0200 Subject: [PATCH 05/25] Extract CoreCLR type map keys from generated LLVM IR Parse the named Java type-name blobs in release and debug CoreCLR LLVM IR, supporting emitted byte strings and legacy integer arrays. Validate required inputs and blob data before writing canonical sorted keys, and cover emitter output and malformed inputs with focused task tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tasks/ExtractTypeMapKeysFromLlvmIr.cs | 304 ++++++++++++++++++ .../ExtractTypeMapKeysFromLlvmIrTests.cs | 299 +++++++++++++++++ 2 files changed, 603 insertions(+) create mode 100644 src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromLlvmIr.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/ExtractTypeMapKeysFromLlvmIrTests.cs diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromLlvmIr.cs b/src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromLlvmIr.cs new file mode 100644 index 00000000000..792e428cdc7 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromLlvmIr.cs @@ -0,0 +1,304 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using Microsoft.Android.Build.Tasks; +using Microsoft.Build.Framework; + +namespace Xamarin.Android.Tasks; + +public class ExtractTypeMapKeysFromLlvmIr : AndroidTask +{ + static readonly UTF8Encoding Utf8 = new UTF8Encoding (false, true); + + public override string TaskPrefix => "ETMKLI"; + + [Required] + public ITaskItem [] LlvmIrFiles { get; set; } = []; + + [Required] + public string OutputFile { get; set; } = ""; + + public override bool RunTask () + { + string source = "LLVM IR"; + try { + if (LlvmIrFiles.Length == 0) { + throw new InvalidDataException ("At least one LLVM IR type map file is required."); + } + if (OutputFile.IsNullOrWhiteSpace ()) { + throw new InvalidDataException ("An output file is required."); + } + + var keys = new SortedSet (StringComparer.Ordinal); + foreach (ITaskItem item in LlvmIrFiles) { + source = item.ItemSpec; + using var input = new StreamReader (source, Utf8, detectEncodingFromByteOrderMarks: false); + new TypeMapBlobReader (input, keys).Read (); + } + + source = OutputFile; + string? directory = Path.GetDirectoryName (OutputFile); + if (!directory.IsNullOrEmpty ()) { + Directory.CreateDirectory (directory); + } + using var output = new StreamWriter (OutputFile, append: false, Utf8) { NewLine = "\n" }; + foreach (string key in keys) { + output.WriteLine (key); + } + return !Log.HasLoggedErrors; + } catch (Exception ex) when (ex is InvalidDataException || ex is IOException || ex is UnauthorizedAccessException || ex is ArgumentException || ex is NotSupportedException) { + Log.LogCodedError ("XA4327", Properties.Resources.XA4327, source, ex.Message); + return false; + } + } + + // Only parse the two CoreCLR string-blob globals, not comments, managed names, or other LLVM strings. + // Current writers use c"..." literals; older writers used multiline arrays of i8 u0xNN values. + sealed class TypeMapBlobReader + { + readonly TextReader input; + readonly SortedSet keys; + readonly List nameBytes = new List (); + long expectedSize; + long bytesRead; + int line = 1; + + public TypeMapBlobReader (TextReader input, SortedSet keys) + { + this.input = input; + this.keys = keys; + } + + public void Read () + { + bool found = false; + while (SkipTrivia () >= 0) { + if (input.Peek () != '@') { + SkipLine (); + continue; + } + + string symbol = ReadToken (); + if (symbol != "@java_type_names" && symbol != "@type_map_java_type_names") { + SkipLine (); + continue; + } + if (found) { + throw Invalid ("More than one CoreCLR Java type-name blob was found."); + } + + found = true; + ReadBlob (); + } + if (!found) { + throw Invalid ("No supported CoreCLR Java type-name blob was found."); + } + } + + void ReadBlob () + { + Expect ("="); + string token = ReadToken (); + if (token == "dso_local") { + token = ReadToken (); + } + if (token == "local_unnamed_addr") { + token = ReadToken (); + } + if (token != "constant") { + throw Invalid ("Expected a constant Java type-name blob."); + } + Expect ("["); + if (!Int64.TryParse (ReadToken (), NumberStyles.None, CultureInfo.InvariantCulture, out expectedSize)) { + throw Invalid ("Invalid Java type-name blob size."); + } + Expect ("x"); + Expect ("i8"); + Expect ("]"); + + int initializer = SkipTrivia (); + if (initializer == 'c') { + ReadByteString (); + } else if (initializer == '[') { + ReadByteArray (); + } else { + throw Invalid ("Unsupported Java type-name blob initializer."); + } + + if (bytesRead != expectedSize || nameBytes.Count != 0) { + throw Invalid ("Java type-name blob size does not match its data, or its last name is not NUL-terminated."); + } + + Expect (","); + Expect ("align"); + if (!UInt64.TryParse (ReadToken (), NumberStyles.None, CultureInfo.InvariantCulture, out ulong alignment) || + alignment == 0 || (alignment & (alignment - 1)) != 0) { + throw Invalid ("Invalid Java type-name blob alignment."); + } + while (input.Peek () == ' ' || input.Peek () == '\t' || input.Peek () == '\r') { + ReadChar (); + } + if (input.Peek () == ';') { + SkipLine (); + } else if (input.Peek () != '\n' && input.Peek () != -1) { + throw Invalid ("Unexpected content after the Java type-name blob."); + } + } + + void ReadByteString () + { + ReadChar (); // c + if (ReadChar () != '"') { + throw Invalid ("Expected a quoted LLVM byte string."); + } + + int c; + while ((c = ReadChar ()) != '"') { + if (c == '\\') { + int high = HexValue (ReadChar ()); + int low = HexValue (ReadChar ()); + if (high < 0 || low < 0) { + throw Invalid ("Invalid hexadecimal escape in the Java type-name blob."); + } + AddByte ((byte)((high << 4) | low)); + } else { + if (c < 32 || c >= 127) { + throw Invalid ("Invalid or unterminated LLVM byte string."); + } + AddByte ((byte)c); + } + } + } + + void ReadByteArray () + { + Expect ("["); + if (SkipTrivia () == ']') { + ReadChar (); + return; + } + while (true) { + Expect ("i8"); + string value = ReadToken (); + byte b; + if (value.StartsWith ("u0x", StringComparison.Ordinal)) { + if (value.Length != 5 || !Byte.TryParse (value.Substring (3), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out b)) { + throw Invalid ("Invalid hexadecimal i8 value in the Java type-name blob."); + } + } else if (!Byte.TryParse (value, NumberStyles.None, CultureInfo.InvariantCulture, out b)) { + throw Invalid ("Invalid i8 value in the Java type-name blob."); + } + AddByte (b); + + string separator = ReadToken (); + if (separator == "]") { + return; + } + if (separator != ",") { + throw Invalid ("Expected a comma or the end of the Java type-name byte array."); + } + } + } + + void AddByte (byte b) + { + if (bytesRead >= expectedSize) { + throw Invalid ("Java type-name blob contains more bytes than its declared size."); + } + bytesRead++; + if (b != 0) { + nameBytes.Add (b); + return; + } + string name = Utf8.GetString (nameBytes.ToArray ()); + nameBytes.Clear (); + ValidateName (name); + keys.Add (name); + } + + void ValidateName (string name) + { + if (name.Length == 0 || name [0] == '/' || name [name.Length - 1] == '/' || name.Contains ("//")) { + throw Invalid ("An empty Java class name or package segment was found."); + } + foreach (char c in name) { + if (Char.IsWhiteSpace (c) || Char.IsControl (c) || ".;[]\\\"'*!?:,{}()#@<>%".IndexOf (c) >= 0) { + throw Invalid ("A noncanonical Java class name was found in the blob."); + } + } + } + + void Expect (string expected) + { + if (ReadToken () != expected) { + throw Invalid ($"Expected '{expected}' in the Java type-name blob declaration."); + } + } + + string ReadToken () + { + int c = SkipTrivia (); + if (c < 0) { + throw Invalid ("Unexpected end of LLVM IR."); + } + if (IsPunctuation (c)) { + return ((char)ReadChar ()).ToString (); + } + var token = new StringBuilder (); + while (c >= 0 && !Char.IsWhiteSpace ((char)c) && c != ';' && !IsPunctuation (c)) { + token.Append ((char)ReadChar ()); + c = input.Peek (); + } + return token.ToString (); + } + + static bool IsPunctuation (int c) => c == '[' || c == ']' || c == '=' || c == ','; + + int SkipTrivia () + { + int c; + while ((c = input.Peek ()) >= 0) { + if (Char.IsWhiteSpace ((char)c) || c == '\uFEFF') { + ReadChar (); + } else if (c == ';') { + SkipLine (); + } else { + break; + } + } + return c; + } + + void SkipLine () + { + int c; + do { + c = ReadChar (); + } while (c >= 0 && c != '\n'); + } + + int ReadChar () + { + int c = input.Read (); + if (c == '\n') { + line++; + } + return c; + } + + static int HexValue (int c) + { + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + return c >= 'A' && c <= 'F' ? c - 'A' + 10 : -1; + } + + InvalidDataException Invalid (string message) => new InvalidDataException ($"Line {line}: {message}"); + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/ExtractTypeMapKeysFromLlvmIrTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/ExtractTypeMapKeysFromLlvmIrTests.cs new file mode 100644 index 00000000000..4a2c21aab11 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/ExtractTypeMapKeysFromLlvmIrTests.cs @@ -0,0 +1,299 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using NUnit.Framework; +using Xamarin.Android.Tasks; +using Xamarin.Android.Tasks.LLVMIR; +using Xamarin.Android.Tools; + +namespace Xamarin.Android.Build.Tests.Tasks; + +[TestFixture] +public class ExtractTypeMapKeysFromLlvmIrTests +{ + const string ReleaseSymbol = "java_type_names"; + const string DebugSymbol = "type_map_java_type_names"; + static readonly UTF8Encoding Utf8 = new UTF8Encoding (false, true); + string directory = ""; + List errors = new List (); + + [SetUp] + public void SetUp () + { + directory = Path.Combine (Path.GetTempPath (), nameof (ExtractTypeMapKeysFromLlvmIrTests), Guid.NewGuid ().ToString ("N")); + Directory.CreateDirectory (directory); + errors.Clear (); + } + + [TearDown] + public void TearDown () + { + Directory.Delete (directory, recursive: true); + } + + [Test] + public void ReadsEmittedBlobs ( + [Values (ReleaseSymbol, DebugSymbol)] string symbol, + [Values (false, true)] bool comments, + [Values (AndroidTargetArch.Arm64, AndroidTargetArch.X86_64)] AndroidTargetArch arch, + [Values (false, true)] bool empty) + { + string [] names = empty ? [] : ["test/Outer$Inner", "test/\u017Dlu\u0165ou\u010Dk\u00FD", "android/app/Activity", "test/\U00010400Type", "test/Outer$Inner"]; + string input = WriteEmittedBlob ("typemaps.ll", symbol, names, comments, arch); + var task = CreateTask (input); + + Assert.IsTrue (task.Execute ()); + Assert.That (errors, Is.Empty); + AssertOutput (task.OutputFile, names); + } + + [Test] + public void UnionsAllAbiFilesAndOverwritesOutput () + { + string first = WriteEmittedBlob ("typemaps.arm64-v8a.ll", ReleaseSymbol, ["z/Last", "test/Outer$Inner"], false, AndroidTargetArch.Arm64); + string second = WriteEmittedBlob ("typemaps.x86_64.ll", ReleaseSymbol, ["a/First", "test/Outer$Inner"], true, AndroidTargetArch.X86_64); + var task = CreateTask (first, second, first); + Assert.IsTrue (task.Execute ()); + AssertOutput (task.OutputFile, ["a/First", "test/Outer$Inner", "z/Last"]); + + DateTime oldTime = new DateTime (2000, 1, 1, 0, 0, 0, DateTimeKind.Utc); + File.SetLastWriteTimeUtc (task.OutputFile, oldTime); + Assert.IsTrue (task.Execute ()); + Assert.That (File.GetLastWriteTimeUtc (task.OutputFile), Is.GreaterThan (oldTime)); + AssertOutput (task.OutputFile, ["a/First", "test/Outer$Inner", "z/Last"]); + + task.LlvmIrFiles = [new TaskItem (WriteEmittedBlob ("empty.ll", ReleaseSymbol, [], false, AndroidTargetArch.Arm64))]; + Assert.IsTrue (task.Execute ()); + AssertOutput (task.OutputFile, []); + } + + [Test] + public void ReadsBlobsAcrossWriterAndReaderBufferBoundaries () + { + string [] names = Enumerable.Range (0, 1024) + .Select (i => "test/Type" + i.ToString (CultureInfo.InvariantCulture) + "$\u03A9") + .Concat (["test/" + new string ('A', 4094) + "$\U00010400"]) + .ToArray (); + var task = CreateTask (WriteEmittedBlob ("large.ll", DebugSymbol, names, false, AndroidTargetArch.Arm64)); + + Assert.IsTrue (task.Execute ()); + AssertOutput (task.OutputFile, names); + } + + [Test] + public void ReadsLegacyMultilineByteArrays ( + [Values (ReleaseSymbol, DebugSymbol)] string symbol, + [Values (false, true)] bool comments, + [Values (false, true)] bool empty, + [Values (false, true)] bool hexadecimal) + { + string [] names = empty ? [] : ["test/Outer$Inner", "test/\u03A9", "a/First"]; + byte [] bytes = Utf8.GetBytes (String.Join ("\0", names) + (empty ? "" : "\0")); + var ir = new StringBuilder ($"@{symbol} = dso_local local_unnamed_addr constant [{bytes.Length} x i8] [\n"); + for (int i = 0; i < bytes.Length; i++) { + if (i > 0) { + ir.Append (",\n"); + } + if (comments) { + ir.Append ("; 'not/a/Class' @ 123 ; ] c\"bogus\\00\"\n"); + } + ir.Append ("i8 "); + ir.Append (hexadecimal ? "u0x" + bytes [i].ToString ("x2", CultureInfo.InvariantCulture) : bytes [i].ToString (CultureInfo.InvariantCulture)); + } + ir.Append ("\n], align 16 ; optional trailing comment\n"); + var task = CreateTask (WriteInput (ir.ToString ())); + + Assert.IsTrue (task.Execute ()); + AssertOutput (task.OutputFile, names); + } + + [Test] + public void IgnoresUnrelatedGlobalsAndMisleadingComments () + { + string source = """ + ; @java_type_names = constant [14 x i8] c"not/a/Class\00", align 1 + @managed_type_names = constant [14 x i8] c"not/a/Class\00", align 1 + @java_type_names_size = constant i64 2, align 8 + @java_type_names_suffix = constant [14 x i8] c"not/a/Class\00", align 1 + @java_type_names = dso_local local_unnamed_addr constant [7 x i8] + c"a\2FB\24\CE\A9\00", align 8 + """; + var task = CreateTask (WriteInput (source)); + Assert.IsTrue (task.Execute ()); + AssertOutput (task.OutputFile, ["a/B$\u03A9"]); + } + + [TestCase ("")] + [TestCase ("; an LLVM IR file without a supported type map\n")] + [TestCase ("@type_map = constant [0 x i8] c\"\", align 1\n")] + [TestCase ("@java_type_names = external constant [0 x i8]\n")] + [TestCase ("@java_type_names = constant [0 x i16] [], align 1\n")] + [TestCase ("@java_type_names = constant [-1 x i8] c\"\", align 1\n")] + [TestCase ("@java_type_names = constant [9223372036854775808 x i8] c\"\", align 1\n")] + [TestCase ("@java_type_names = constant [0 x i8] zeroinitializer, align 1\n")] + [TestCase ("@java_type_names = constant [0 x i8] c\"\", align 3\n")] + [TestCase ("@java_type_names = constant [0 x i8] c\"\", align 1 garbage\n")] + [TestCase ("@java_type_names = constant [0 x i8] c\"\"")] + [TestCase ("@java_type_names = constant [0 x i8] c\"\", align 1\n@java_type_names = constant [0 x i8] c\"\", align 1\n")] + [TestCase ("@java_type_names = constant [0 x i8] c\"\", align 1\n@type_map_java_type_names = constant [0 x i8] c\"\", align 1\n")] + [TestCase ("@java_type_names = constant [2 x i8] c\"A\\00\", align 1\n@type_map_java_type_names = external constant [0 x i8]\n")] + [TestCase ("@java_type_names = constant [2 x i8] c\"A\\0")] + [TestCase ("@java_type_names = constant [2 x i8] c\"A\\zz\", align 1\n")] + [TestCase ("@java_type_names = constant [2 x i8] c\"A\\0g\", align 1\n")] + [TestCase ("@java_type_names = constant [2 x i8] c\"AB\", align 1\n")] + [TestCase ("@java_type_names = constant [3 x i8] c\"A\\00\", align 1\n")] + [TestCase ("@java_type_names = constant [3 x i8] c\"A\\00\\00\", align 1\n")] + [TestCase ("@java_type_names = constant [1 x i8] c\"A\\00\", align 1\n")] + [TestCase ("@java_type_names = constant [2 x i8] c\"\\FF\\00\", align 1\n")] + [TestCase ("@java_type_names = constant [3 x i8] c\"\\C0\\AF\\00\", align 1\n")] + [TestCase ("@java_type_names = constant [4 x i8] c\"\\ED\\A0\\80\\00\", align 1\n")] + [TestCase ("@java_type_names = constant [2 x i8] [i8 u0x41 i8 u0x00], align 1\n")] + [TestCase ("@java_type_names = constant [2 x i8] [i8 u0x41, i8 u0x00,], align 1\n")] + [TestCase ("@java_type_names = constant [2 x i8] [i8 u0x41, i8 u0x100], align 1\n")] + [TestCase ("@java_type_names = constant [2 x i8] [i8 u0x41, i8 256], align 1\n")] + [TestCase ("@java_type_names = constant [2 x i8] [i8 u0x41, i16 u0x00], align 1\n")] + [TestCase ("@java_type_names = constant [2 x i8] [i8 u0x41,")] + public void RejectsUnsupportedOrMalformedInput (string source) + { + AssertFails (source); + } + + [TestCase ("")] + [TestCase ("test/Type[1]")] + [TestCase ("[Ltest/Type;")] + [TestCase ("test.Type")] + [TestCase ("test//Type")] + [TestCase ("/Type")] + [TestCase ("test/")] + [TestCase ("test/Type\n-keep class **")] + [TestCase ("test/Type\r")] + [TestCase ("test/Type\t")] + [TestCase ("test/*")] + [TestCase ("test/?")] + [TestCase ("test/!Type")] + [TestCase ("test/Type;")] + [TestCase ("test/Type\"")] + [TestCase ("test/Type\\")] + [TestCase ("test/<1>")] + [TestCase ("test/%")] + public void RejectsInvalidClassNames (string name) + { + var task = CreateTask (WriteEmittedBlob ("invalid.ll", ReleaseSymbol, [name], false, AndroidTargetArch.Arm64)); + Assert.IsFalse (task.Execute ()); + Assert.That (errors.Single ().Code, Is.EqualTo ("XA4327")); + Assert.That (File.Exists (task.OutputFile), Is.False); + } + + [Test] + public void RequiresEverySourceFile () + { + string valid = WriteEmittedBlob ("valid.ll", DebugSymbol, [], false, AndroidTargetArch.Arm64); + var task = CreateTask (valid, Path.Combine (directory, "missing.ll")); + Assert.IsFalse (task.Execute ()); + Assert.That (errors.Single ().Code, Is.EqualTo ("XA4327")); + Assert.That (File.Exists (task.OutputFile), Is.False); + } + + [Test] + public void RejectsOneUnsupportedAbiAmongValidInputs () + { + string valid = WriteEmittedBlob ("valid.ll", DebugSymbol, [], false, AndroidTargetArch.Arm64); + var task = CreateTask (valid, WriteInput ("@unrelated = constant i32 1, align 4\n")); + Assert.IsFalse (task.Execute ()); + Assert.That (errors.Single ().Code, Is.EqualTo ("XA4327")); + Assert.That (File.Exists (task.OutputFile), Is.False); + } + + [Test] + public void RequiresInputs () + { + var task = CreateTask (); + Assert.IsFalse (task.Execute ()); + Assert.That (errors.Single ().Code, Is.EqualTo ("XA4327")); + Assert.That (File.Exists (task.OutputFile), Is.False); + } + + [TestCase ("")] + [TestCase (" ")] + public void RequiresOutput (string output) + { + var task = CreateTask (WriteEmittedBlob ("valid.ll", ReleaseSymbol, [], false, AndroidTargetArch.Arm64)); + task.OutputFile = output; + Assert.IsFalse (task.Execute ()); + Assert.That (errors.Single ().Code, Is.EqualTo ("XA4327")); + } + + [Test] + public void ReportsOutputIoErrors () + { + var task = CreateTask (WriteEmittedBlob ("valid.ll", ReleaseSymbol, [], false, AndroidTargetArch.Arm64)); + task.OutputFile = directory; + Assert.IsFalse (task.Execute ()); + Assert.That (errors.Single ().Code, Is.EqualTo ("XA4327")); + } + + [Test] + public void RejectsInvalidSourceEncoding () + { + string input = Path.Combine (directory, "invalid-encoding.ll"); + File.WriteAllBytes (input, [0xff, 0xff]); + var task = CreateTask (input); + Assert.IsFalse (task.Execute ()); + Assert.That (errors.Single ().Code, Is.EqualTo ("XA4327")); + } + + void AssertFails (string source) + { + var task = CreateTask (WriteInput (source)); + Assert.IsFalse (task.Execute ()); + Assert.That (errors.Single ().Code, Is.EqualTo ("XA4327")); + Assert.That (File.Exists (task.OutputFile), Is.False); + } + + ExtractTypeMapKeysFromLlvmIr CreateTask (params string [] inputs) => new ExtractTypeMapKeysFromLlvmIr { + BuildEngine = new MockBuildEngine (TestContext.Out, errors), + LlvmIrFiles = inputs.Select (path => (ITaskItem)new TaskItem (path)).ToArray (), + OutputFile = Path.Combine (directory, "output", "keys.txt"), + }; + + string WriteInput (string text) + { + string path = Path.Combine (directory, "input.ll"); + File.WriteAllText (path, text, Utf8); + return path; + } + + string WriteEmittedBlob (string fileName, string symbol, string [] names, bool comments, AndroidTargetArch arch) + { + var log = new TaskLoggingHelper (new MockBuildEngine (TestContext.Out), "test"); + var module = new LlvmIrModule (new LlvmIrTypeCache (), log); + var blob = new LlvmIrStringBlob (); + foreach (string name in names) { + blob.Add (name); + } + module.AddGlobalVariable (symbol, blob, LlvmIrVariableOptions.GlobalConstant, "Misleading comment: not/a/RetainedClass"); + module.AddGlobalVariable ("managed_type_names", "not/a/RetainedClass"); + module.AfterConstruction (); + string path = Path.Combine (directory, fileName); + var generator = LlvmIrGenerator.Create (arch, path); + generator.EmitComments = comments; + using var writer = new StreamWriter (path, append: false, Utf8); + generator.Generate (writer, module); + return path; + } + + static void AssertOutput (string path, string [] names) + { + string expected = String.Join ("\n", names.Distinct (StringComparer.Ordinal).OrderBy (name => name, StringComparer.Ordinal)); + if (expected.Length > 0) { + expected += "\n"; + } + Assert.That (File.Exists (path), Is.True); + Assert.That (File.ReadAllBytes (path), Is.EqualTo (Utf8.GetBytes (expected)), "Exact UTF-8 without BOM and LF output"); + } +} From 78d358dc3ccad303b465cb28a7a7904445bdf5e6 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 18 Sep 2026 09:07:32 +0200 Subject: [PATCH 06/25] Extract retained Java keys from linked typemap assemblies Inspect surviving assembly TypeMapAttribute metadata and exercise emitted assemblies through the pinned real ILLink in standalone tests. Source-link retained-key tasks for repeatable standalone coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tasks/ExtractTypeMapKeysFromAssemblies.cs | 150 +++++++++ .../ExtractTypeMapKeysFromAssembliesTests.cs | 304 ++++++++++++++++++ ....Android.Sdk.TrimmableTypeMap.Tests.csproj | 16 + .../TypeMapTaskBuildEngine.cs | 20 ++ 4 files changed, 490 insertions(+) create mode 100644 src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromAssemblies.cs create mode 100644 tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromAssembliesTests.cs create mode 100644 tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TypeMapTaskBuildEngine.cs diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromAssemblies.cs b/src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromAssemblies.cs new file mode 100644 index 00000000000..b458084180a --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromAssemblies.cs @@ -0,0 +1,150 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Text; +using Microsoft.Android.Build.Tasks; +using Microsoft.Build.Framework; + +namespace Xamarin.Android.Tasks +{ + public class ExtractTypeMapKeysFromAssemblies : AndroidTask + { + public override string TaskPrefix => "ETMKA"; + + [Required] + public ITaskItem [] LinkedAssemblies { get; set; } = []; + + [Required] + public string OutputFile { get; set; } = ""; + + public override bool RunTask () + { + var keys = new SortedSet (StringComparer.Ordinal); + foreach (var assembly in LinkedAssemblies) { + try { + ReadKeys (assembly.ItemSpec, keys); + } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is BadImageFormatException || ex is ArgumentException) { + Log.LogCodedError ("XA4327", Properties.Resources.XA4327, assembly.ItemSpec, ex.Message); + } + } + + if (Log.HasLoggedErrors) { + return false; + } + + try { + var directory = Path.GetDirectoryName (OutputFile); + if (!directory.IsNullOrEmpty ()) { + Directory.CreateDirectory (directory); + } + using var writer = new StreamWriter (OutputFile, append: false, new UTF8Encoding (encoderShouldEmitUTF8Identifier: false)); + writer.NewLine = "\n"; + foreach (string key in keys) { + writer.WriteLine (key); + } + } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is ArgumentException) { + Log.LogCodedError ("XA4327", Properties.Resources.XA4327, OutputFile, ex.Message); + } + + return !Log.HasLoggedErrors; + } + + void ReadKeys (string assemblyPath, SortedSet keys) + { + using var stream = File.OpenRead (assemblyPath); + using var pe = new PEReader (stream); + var reader = pe.GetMetadataReader (); + + // ILLink preserves the surviving TypeMap attributes, including their conditional + // target argument. Alias arrays and Register attributes are not the linked map. + foreach (var handle in reader.GetAssemblyDefinition ().GetCustomAttributes ()) { + var attribute = reader.GetCustomAttribute (handle); + if (reader.GetCustomAttributeFullName (attribute, Log) != "System.Runtime.InteropServices.TypeMapAttribute`1") { + continue; + } + + int argumentCount = GetArgumentCount (reader, attribute); + var blob = reader.GetBlobReader (attribute.Value); + if (blob.ReadUInt16 () != 1) { + throw new BadImageFormatException ("Invalid TypeMap attribute prolog."); + } + string? key = blob.ReadSerializedString (); + if (key.IsNullOrEmpty () || key.IndexOfAny (new [] { '\r', '\n', '\0' }) >= 0) { + throw new BadImageFormatException ("Invalid TypeMap key."); + } + for (int i = 1; i < argumentCount; i++) { + if (string.IsNullOrEmpty (blob.ReadSerializedString ())) { + throw new BadImageFormatException ("Invalid TypeMap type argument."); + } + } + if (blob.ReadUInt16 () != 0 || blob.RemainingBytes != 0) { + throw new BadImageFormatException ("Invalid TypeMap attribute arguments."); + } + + keys.Add (NormalizeKey (key)); + } + } + + static int GetArgumentCount (MetadataReader reader, CustomAttribute attribute) + { + BlobHandle signature; + if (attribute.Constructor.Kind == HandleKind.MemberReference) { + signature = reader.GetMemberReference ((MemberReferenceHandle) attribute.Constructor).Signature; + } else { + signature = reader.GetMethodDefinition ((MethodDefinitionHandle) attribute.Constructor).Signature; + } + var blob = reader.GetBlobReader (signature); + var header = blob.ReadSignatureHeader (); + int count = blob.ReadCompressedInteger (); + if (header.Kind != SignatureKind.Method || !header.IsInstance || header.IsGeneric || + (count != 2 && count != 3) || blob.ReadSignatureTypeCode () != SignatureTypeCode.Void || + blob.ReadSignatureTypeCode () != SignatureTypeCode.String) { + throw new BadImageFormatException ("Invalid TypeMap constructor signature."); + } + for (int i = 1; i < count; i++) { + if (blob.ReadSignatureTypeCode () != SignatureTypeCode.TypeHandle) { + throw new BadImageFormatException ("Invalid TypeMap constructor type parameter."); + } + var typeHandle = blob.ReadTypeHandle (); + string ns; + string name; + if (typeHandle.Kind == HandleKind.TypeReference) { + var type = reader.GetTypeReference ((TypeReferenceHandle) typeHandle); + ns = reader.GetString (type.Namespace); + name = reader.GetString (type.Name); + } else if (typeHandle.Kind == HandleKind.TypeDefinition) { + var type = reader.GetTypeDefinition ((TypeDefinitionHandle) typeHandle); + ns = reader.GetString (type.Namespace); + name = reader.GetString (type.Name); + } else { + throw new BadImageFormatException ("Invalid TypeMap constructor type parameter."); + } + if (ns != "System" || name != "Type") { + throw new BadImageFormatException ("Invalid TypeMap constructor type parameter."); + } + } + if (blob.RemainingBytes != 0) { + throw new BadImageFormatException ("Invalid TypeMap constructor signature."); + } + return count; + } + + static string NormalizeKey (string key) + { + int start = key.LastIndexOf ('['); + if (start <= 0 || start == key.Length - 2 || key [key.Length - 1] != ']') { + return key; + } + for (int i = start + 1; i < key.Length - 1; i++) { + if (key [i] < '0' || key [i] > '9') { + return key; + } + } + return key.Substring (0, start); + } + } +} diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromAssembliesTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromAssembliesTests.cs new file mode 100644 index 00000000000..287bdbd7063 --- /dev/null +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromAssembliesTests.cs @@ -0,0 +1,304 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Reflection.PortableExecutable; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Xamarin.Android.Tasks; +using Xunit; +using TaskItem = Microsoft.Build.Utilities.TaskItem; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap.Tests; + +public class ExtractTypeMapKeysFromAssembliesTests : IDisposable +{ + static readonly Version RuntimeVersion = new (11, 0, 0, 0); + readonly string directory = Path.Combine (Path.GetTempPath (), nameof (ExtractTypeMapKeysFromAssembliesTests), Guid.NewGuid ().ToString ("N")); + + public ExtractTypeMapKeysFromAssembliesTests () => Directory.CreateDirectory (directory); + + public void Dispose () => Directory.Delete (directory, recursive: true); + + static TypeMapAttributeData Entry (string key, string? target = null) => new () { + MapKey = key, + ProxyTypeReference = "System.Object, System.Runtime", + TargetTypeReference = target, + }; + + string Emit (string name, params TypeMapAttributeData [] entries) + { + string path = Path.Combine (directory, name + ".dll"); + Directory.CreateDirectory (Path.GetDirectoryName (path) ?? throw new InvalidOperationException ()); + var model = new TypeMapAssemblyData { + AssemblyName = Path.GetFileName (name), + ModuleName = Path.GetFileName (path), + }; + model.Entries.AddRange (entries); + using var stream = File.Create (path); + new TypeMapAssemblyEmitter (RuntimeVersion).Emit (model, stream); + return path; + } + + (ExtractTypeMapKeysFromAssemblies task, TypeMapTaskBuildEngine engine) CreateTask (params string [] inputs) + { + var engine = new TypeMapTaskBuildEngine (); + return (new ExtractTypeMapKeysFromAssemblies { + BuildEngine = engine, + LinkedAssemblies = inputs.Select (p => new TaskItem (p)).ToArray (), + OutputFile = Path.Combine (directory, "output", "keys.txt"), + }, engine); + } + + [Fact] + public void UnionsAllAssembliesAndRidsWithExactCanonicalEncoding () + { + string arm64 = Emit ("android-arm64/_Bindings.TypeMap", + Entry ("test/Zebra"), Entry ("test/Outer$Inner"), Entry ("test/Alias[0]"), Entry ("test/\u00e9clair")); + string x64 = Emit ("android-x64/_Bindings.TypeMap", + Entry ("test/Alpha", "System.String, System.Runtime"), Entry ("test/Outer$Inner"), Entry ("test/Alias[1]")); + var (task, _) = CreateTask (arm64, x64, arm64); + + Assert.True (task.Execute ()); + byte [] expected = new UTF8Encoding (false).GetBytes ("test/Alias\ntest/Alpha\ntest/Outer$Inner\ntest/Zebra\ntest/\u00e9clair\n"); + Assert.Equal (expected, File.ReadAllBytes (task.OutputFile)); + + File.SetLastWriteTimeUtc (task.OutputFile, new DateTime (2000, 1, 1)); + task.LinkedAssemblies = task.LinkedAssemblies.Reverse ().ToArray (); + Assert.True (task.Execute ()); + Assert.Equal (expected, File.ReadAllBytes (task.OutputFile)); + Assert.True (File.GetLastWriteTimeUtc (task.OutputFile).Year > 2000); + } + + [Theory] + [InlineData ("test/A[123]", "test/A")] + [InlineData ("test/A[000]", "test/A")] + [InlineData ("test/A[9999999999999999999999999999999]", "test/A")] + [InlineData ("test/A[]", "test/A[]")] + [InlineData ("test/A[-1]", "test/A[-1]")] + [InlineData ("test/A[1x]", "test/A[1x]")] + [InlineData ("test/A[1]Extra", "test/A[1]Extra")] + [InlineData ("test/A[1", "test/A[1")] + [InlineData ("test/A[", "test/A[")] + [InlineData ("[0]", "[0]")] + [InlineData ("test/A[\u0661]", "test/A[\u0661]")] + public void NormalizesOnlyFinalNonemptyAsciiDecimalAlias (string key, string expected) + { + var (task, _) = CreateTask (Emit ("Map", Entry (key))); + Assert.True (task.Execute ()); + Assert.Equal (expected + "\n", File.ReadAllText (task.OutputFile)); + } + + [Fact] + public void EmptyStubsAndOrdinaryManagedAssembliesContributeNoKeys () + { + var pe = new PEAssemblyBuilder (RuntimeVersion); + pe.EmitPreamble ("Stub", "Stub.dll"); + string stub = Path.Combine (directory, "Stub.dll"); + using (var stream = File.Create (stub)) { + pe.WritePE (stream); + } + var (task, _) = CreateTask (stub, typeof (ExtractTypeMapKeysFromAssembliesTests).Assembly.Location); + Assert.True (task.Execute ()); + Assert.Empty (File.ReadAllBytes (task.OutputFile)); + + task.LinkedAssemblies = [new TaskItem (Emit ("Map", Entry ("test/Present")))]; + Assert.True (task.Execute ()); + task.LinkedAssemblies = []; + Assert.True (task.Execute ()); + Assert.Empty (File.ReadAllBytes (task.OutputFile)); + } + + [Fact] + public void IgnoresAliasArraysAssociationsAssemblyTargetsAndUnrelatedAttributes () + { + var model = new TypeMapAssemblyData { AssemblyName = "Map", ModuleName = "Map.dll" }; + model.Entries.Add (Entry ("test/Retained")); + model.AliasHolders.Add (new AliasHolderData { + Namespace = "_TypeMap.Aliases", TypeName = "StaleAliases", AliasKeys = ["test/Removed[0]", "test/Removed[1]"], + }); + model.Associations.Add (new TypeMapAssociationData { + SourceTypeReference = "Unrelated.Source, Unrelated", + AliasProxyTypeReference = "Unrelated.Proxy, Unrelated", + }); + string map = Path.Combine (directory, "Map.dll"); + using (var stream = File.Create (map)) { + new TypeMapAssemblyEmitter (RuntimeVersion).Emit (model, stream); + } + string root = Path.Combine (directory, "Root.dll"); + using (var stream = File.Create (root)) { + new RootTypeMapAssemblyGenerator (RuntimeVersion).Generate (["Map"], useSharedTypemapUniverse: false, stream); + } + var (task, _) = CreateTask (root, map); + Assert.True (task.Execute ()); + Assert.Equal ("test/Retained\n", File.ReadAllText (task.OutputFile)); + } + + [Theory] + [InlineData ("missing")] + [InlineData ("malformed")] + [InlineData ("directory")] + public void BadRequiredAssemblyFailsWithoutWritingOutput (string kind) + { + string path = Path.Combine (directory, "bad.dll"); + if (kind == "malformed") { + File.WriteAllBytes (path, [1, 2, 3]); + } else if (kind == "directory") { + Directory.CreateDirectory (path); + } + var (task, engine) = CreateTask (Emit ("Valid", Entry ("test/Valid")), path); + Assert.False (task.Execute ()); + Assert.Contains (engine.Errors, e => e.Code == "XA4327" && e.Message != null && e.Message.Contains (path, StringComparison.Ordinal)); + Assert.False (File.Exists (task.OutputFile)); + } + + [Theory] + [InlineData ("")] + [InlineData ("test/Invalid\nName")] + [InlineData ("test/Invalid\rName")] + [InlineData ("test/Invalid\0Name")] + public void InvalidKeyFails (string key) + { + var (task, engine) = CreateTask (Emit ("Map", Entry (key))); + Assert.False (task.Execute ()); + Assert.Contains (engine.Errors, e => e.Code == "XA4327"); + } + + [Fact] + public void OutputWriteFailureIsReported () + { + var (task, engine) = CreateTask (Emit ("Map", Entry ("test/Valid"))); + task.OutputFile = directory; + Assert.False (task.Execute ()); + Assert.Contains (engine.Errors, e => e.Code == "XA4327"); + } + + [Fact] + public async Task RealILLinkRetainsOnlyLiveTypeMapAttributes () + { + EmitTargets (); + string input = Emit ("_Bindings.TypeMap", + Entry ("test/Unconditional"), + Entry ("test/Surviving", "Targets.Surviving, Targets"), + Entry ("test/Removed", "Targets.Removed, Targets"), + Entry ("test/Alias[0]", "Targets.Surviving, Targets"), + Entry ("test/Alias[1]", "Targets.Removed, Targets")); + string root = EmitLinkerRoot ("_Bindings.TypeMap"); + var (before, _) = CreateTask (input); + Assert.True (before.Execute ()); + Assert.Contains ("test/Removed\n", File.ReadAllText (before.OutputFile), StringComparison.Ordinal); + + string linkedDirectory = Path.Combine (directory, "linked"); + await RunLinker (root, linkedDirectory); + string linked = Path.Combine (linkedDirectory, "_Bindings.TypeMap.dll"); + Assert.True (File.Exists (linked)); + var (after, _) = CreateTask (linked); + Assert.True (after.Execute ()); + Assert.Equal ("test/Alias\ntest/Surviving\ntest/Unconditional\n", File.ReadAllText (after.OutputFile)); + + using var pe = new PEReader (File.OpenRead (linked)); + var reader = pe.GetMetadataReader (); + var keys = new List (); + foreach (var handle in reader.GetAssemblyDefinition ().GetCustomAttributes ()) { + var attribute = reader.GetCustomAttribute (handle); + if (reader.GetCustomAttributeFullName (attribute, after.Log) != "System.Runtime.InteropServices.TypeMapAttribute`1") { + continue; + } + Assert.Equal (HandleKind.MemberReference, attribute.Constructor.Kind); + Assert.Equal (HandleKind.TypeSpecification, reader.GetMemberReference ((MemberReferenceHandle) attribute.Constructor).Parent.Kind); + var blob = reader.GetBlobReader (attribute.Value); + Assert.Equal (1, blob.ReadUInt16 ()); + keys.Add (blob.ReadSerializedString () ?? throw new InvalidOperationException ()); + } + Assert.Equal (new [] { "test/Alias[0]", "test/Surviving", "test/Unconditional" }, keys.OrderBy (k => k, StringComparer.Ordinal)); + } + + void EmitTargets () + { + var pe = new PEAssemblyBuilder (RuntimeVersion); + pe.EmitPreamble ("Targets", "Targets.dll"); + var objectType = pe.Metadata.AddTypeReference (pe.SystemRuntimeRef, pe.Metadata.GetOrAddString ("System"), pe.Metadata.GetOrAddString ("Object")); + foreach (string name in new [] { "Surviving", "Removed" }) { + pe.Metadata.AddTypeDefinition (TypeAttributes.Public, pe.Metadata.GetOrAddString ("Targets"), pe.Metadata.GetOrAddString (name), + objectType, MetadataTokens.FieldDefinitionHandle (1), MetadataTokens.MethodDefinitionHandle (1)); + } + using var stream = File.Create (Path.Combine (directory, "Targets.dll")); + pe.WritePE (stream); + } + + string EmitLinkerRoot (string mapName) + { + var pe = new PEAssemblyBuilder (RuntimeVersion); + pe.EmitPreamble ("Root", "Root.dll"); + var metadata = pe.Metadata; + TypeReferenceHandle TypeRef (EntityHandle scope, string ns, string name) => + metadata.AddTypeReference (scope, metadata.GetOrAddString (ns), metadata.GetOrAddString (name)); + var anchor = TypeRef (pe.FindOrAddAssemblyRef (mapName), "", "__TypeMapAnchor"); + var attributeType = TypeRef (pe.SystemRuntimeInteropServicesRef, "System.Runtime.InteropServices", "TypeMapAssemblyTargetAttribute`1"); + var attributeCtor = pe.AddMemberRef (pe.MakeGenericTypeSpec (attributeType, anchor), ".ctor", + s => s.MethodSignature (isInstanceMethod: true).Parameters (1, r => r.Void (), p => p.AddParameter ().Type ().String ())); + metadata.AddCustomAttribute (EntityHandle.AssemblyDefinition, attributeCtor, pe.BuildAttributeBlob (b => b.WriteSerializedString (mapName))); + var mappingType = TypeRef (pe.SystemRuntimeInteropServicesRef, "System.Runtime.InteropServices", "TypeMapping"); + var dictionaryType = TypeRef (pe.SystemRuntimeRef, "System.Collections.Generic", "IReadOnlyDictionary`2"); + var systemType = TypeRef (pe.SystemRuntimeRef, "System", "Type"); + var getMapping = pe.AddMemberRef (mappingType, "GetOrCreateExternalTypeMapping", + s => s.MethodSignature (genericParameterCount: 1).Parameters (0, r => { + var args = r.Type ().GenericInstantiation (dictionaryType, 2, isValueType: false); + args.AddArgument ().String (); + args.AddArgument ().Type (systemType, isValueType: false); + }, p => { })); + var methodSignature = new BlobBuilder (); + new BlobEncoder (methodSignature).MethodSpecificationSignature (1).AddArgument ().Type (anchor, isValueType: false); + var method = metadata.AddMethodSpecification (getMapping, metadata.GetOrAddBlob (methodSignature)); + metadata.AddTypeDefinition (TypeAttributes.Public, default, metadata.GetOrAddString ("Root"), + TypeRef (pe.SystemRuntimeRef, "System", "Object"), MetadataTokens.FieldDefinitionHandle (1), MetadataTokens.MethodDefinitionHandle (1)); + var survivingType = TypeRef (pe.FindOrAddAssemblyRef ("Targets"), "Targets", "Surviving"); + pe.EmitBody ("Run", MethodAttributes.Public | MethodAttributes.Static, + s => s.MethodSignature ().Parameters (0, r => r.Void (), p => { }), + il => { + il.LoadToken (survivingType); + il.PopValue (); + il.Call (method, 0, returnsValue: true); + il.PopValue (); + il.Return (); + }); + string path = Path.Combine (directory, "Root.dll"); + using var stream = File.Create (path); + pe.WritePE (stream); + return path; + } + + async Task RunLinker (string root, string output) + { + string linker = typeof (ExtractTypeMapKeysFromAssembliesTests).Assembly.GetCustomAttributes () + .Single (a => a.Key == "ILLinkPath").Value ?? throw new InvalidOperationException ("Missing ILLinkPath."); + string runtimeDirectory = Path.GetDirectoryName (typeof (object).Assembly.Location) ?? throw new InvalidOperationException (); + var start = new ProcessStartInfo (Environment.GetEnvironmentVariable ("DOTNET_HOST_PATH") ?? "dotnet") { + RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, + }; + foreach (string arg in new [] { + "exec", "--fx-version", Path.GetFileName (runtimeDirectory), linker, + "-a", Path.GetFileNameWithoutExtension (root), "-reference", root, "-d", directory, "-d", runtimeDirectory, "-out", output, + "--typemap-entry-assembly", "Root", "--skip-unresolved", "true", + }) { + start.ArgumentList.Add (arg); + } + using var process = Process.Start (start) ?? throw new InvalidOperationException ("Cannot start ILLink."); + var stdout = process.StandardOutput.ReadToEndAsync (); + var stderr = process.StandardError.ReadToEndAsync (); + using var timeout = new CancellationTokenSource (TimeSpan.FromMinutes (2)); + try { + await process.WaitForExitAsync (timeout.Token); + } catch (OperationCanceledException) { + process.Kill (entireProcessTree: true); + throw; + } + Assert.True (process.ExitCode == 0, await stdout + await stderr); + } + +} diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests.csproj b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests.csproj index bb8aa7c1a02..aff79f1357e 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests.csproj +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests.csproj @@ -8,6 +8,7 @@ Microsoft.Android.Sdk.TrimmableTypeMap.Tests true ..\..\product.snk + true @@ -25,10 +26,12 @@ + + false @@ -44,6 +47,19 @@ + + + + + + + + + + + + + diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TypeMapTaskBuildEngine.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TypeMapTaskBuildEngine.cs new file mode 100644 index 00000000000..d8dc9268678 --- /dev/null +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TypeMapTaskBuildEngine.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using Microsoft.Build.Framework; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap.Tests; + +sealed class TypeMapTaskBuildEngine : IBuildEngine +{ + public List Errors { get; } = []; + public bool ContinueOnError => false; + public int LineNumberOfTaskNode => 0; + public int ColumnNumberOfTaskNode => 0; + public string ProjectFileOfTaskNode => ""; + public void LogErrorEvent (BuildErrorEventArgs e) => Errors.Add (e); + public void LogWarningEvent (BuildWarningEventArgs e) { } + public void LogMessageEvent (BuildMessageEventArgs e) { } + public void LogCustomEvent (CustomBuildEventArgs e) { } + public bool BuildProjectFile (string projectFileName, string [] targetNames, IDictionary globalProperties, IDictionary targetOutputs) => throw new NotSupportedException (); +} From 72ad527f28d433c3872a06f634859a50cb2f4b9e Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 18 Sep 2026 09:09:23 +0200 Subject: [PATCH 07/25] Share canonical retained class-name validation with adapters Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tasks/GenerateTypeMapProguardConfiguration.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTypeMapProguardConfiguration.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTypeMapProguardConfiguration.cs index bb4f0a3add2..d9b9063ef50 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTypeMapProguardConfiguration.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTypeMapProguardConfiguration.cs @@ -63,7 +63,7 @@ public override bool RunTask () return !Log.HasLoggedErrors; } - static bool IsClassName (string name) + internal static bool IsClassName (string name) { bool first = true; for (int i = 0; i < name.Length; i++) { From 2930136c50ae211cbd0130a5897520964d230cd8 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 18 Sep 2026 09:08:27 +0200 Subject: [PATCH 08/25] Align DGML key validation with the shared ProGuard consumer Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tasks/ExtractTypeMapKeysFromDgml.cs | 2 +- .../Generator/ExtractTypeMapKeysFromDgmlTests.cs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromDgml.cs b/src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromDgml.cs index 84f9073a1c4..20adc2036df 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromDgml.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromDgml.cs @@ -125,13 +125,13 @@ static bool IsJavaClassName (string name) case UnicodeCategory.TitlecaseLetter: case UnicodeCategory.ModifierLetter: case UnicodeCategory.OtherLetter: + case UnicodeCategory.LetterNumber: case UnicodeCategory.CurrencySymbol: case UnicodeCategory.ConnectorPunctuation: break; case UnicodeCategory.DecimalDigitNumber: case UnicodeCategory.NonSpacingMark: case UnicodeCategory.SpacingCombiningMark: - case UnicodeCategory.Format: if (segmentStart) { return false; } diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromDgmlTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromDgmlTests.cs index 83c4829f2c8..0f4793ebdbf 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromDgmlTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromDgmlTests.cs @@ -124,6 +124,7 @@ public void Execute_PreservesUnicodeAndWritesExactOrdinalUtf8Bytes () { const string map = "Example.Type, App;com.\u00e9xample.Peer\u0394$Nested\n" + "Example.Type, App;com.example.\U00010400Peer\n" + + "Example.Type, App;com.example.\u2160Peer\n" + "Example.Type, App;com.example.A\u0301\n" + "Example.Type, App;com.example.a\n" + "Example.Type, App;com.example.Z\n"; @@ -133,7 +134,7 @@ public void Execute_PreservesUnicodeAndWritesExactOrdinalUtf8Bytes () Assert.True (task.Execute ()); Assert.Empty (errors); AssertOutput (task, "com/example/A\u0301\ncom/example/Z\ncom/example/a\n" + - "com/example/\U00010400Peer\ncom/\u00e9xample/Peer\u0394$Nested\n"); + "com/example/\u2160Peer\ncom/example/\U00010400Peer\ncom/\u00e9xample/Peer\u0394$Nested\n"); } [Theory] @@ -325,6 +326,7 @@ public void Execute_ProhibitsLocalExternalEntities () [InlineData ("Example.Type, App;example..Type")] [InlineData ("Example.Type, App;example/Type/")] [InlineData ("Example.Type, App;example. Type")] + [InlineData ("Example.Type, App;example.Type\u200b")] [InlineData ("Example.Type, App;example.*")] [InlineData ("Example.Type, App;-keep class example.Type")] public void Execute_RejectsMalformedMapEntries (string map) From e67597192bf57dd400087d97b85a8982a20b1965 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 18 Sep 2026 09:10:55 +0200 Subject: [PATCH 09/25] Validate linked typemap metadata and canonical class keys Reject absent inputs, invalid assembly metadata and malformed typemap attributes. Reuse canonical name validation after decimal alias normalization. Resolve all fixture references for strict real-ILLink coverage and source-link the LLVM adapter for standalone tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tasks/ExtractTypeMapKeysFromAssemblies.cs | 30 +++- .../ExtractTypeMapKeysFromAssembliesTests.cs | 162 ++++++++++++++++-- ....Android.Sdk.TrimmableTypeMap.Tests.csproj | 1 + 3 files changed, 171 insertions(+), 22 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromAssemblies.cs b/src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromAssemblies.cs index b458084180a..eff665fe11e 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromAssemblies.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromAssemblies.cs @@ -23,6 +23,11 @@ public class ExtractTypeMapKeysFromAssemblies : AndroidTask public override bool RunTask () { + if (LinkedAssemblies.Length == 0) { + Log.LogCodedError ("XA4327", Properties.Resources.XA4327, nameof (LinkedAssemblies), "No linked assemblies were supplied."); + return false; + } + var keys = new SortedSet (StringComparer.Ordinal); foreach (var assembly in LinkedAssemblies) { try { @@ -57,7 +62,13 @@ void ReadKeys (string assemblyPath, SortedSet keys) { using var stream = File.OpenRead (assemblyPath); using var pe = new PEReader (stream); + if (!pe.HasMetadata) { + throw new BadImageFormatException ("The input has no managed metadata."); + } var reader = pe.GetMetadataReader (); + if (!reader.IsAssembly) { + throw new BadImageFormatException ("The input is not a managed assembly."); + } // ILLink preserves the surviving TypeMap attributes, including their conditional // target argument. Alias arrays and Register attributes are not the linked map. @@ -73,7 +84,7 @@ void ReadKeys (string assemblyPath, SortedSet keys) throw new BadImageFormatException ("Invalid TypeMap attribute prolog."); } string? key = blob.ReadSerializedString (); - if (key.IsNullOrEmpty () || key.IndexOfAny (new [] { '\r', '\n', '\0' }) >= 0) { + if (key.IsNullOrEmpty ()) { throw new BadImageFormatException ("Invalid TypeMap key."); } for (int i = 1; i < argumentCount; i++) { @@ -85,22 +96,31 @@ void ReadKeys (string assemblyPath, SortedSet keys) throw new BadImageFormatException ("Invalid TypeMap attribute arguments."); } - keys.Add (NormalizeKey (key)); + key = NormalizeKey (key); + if (!GenerateTypeMapProguardConfiguration.IsClassName (key)) { + throw new BadImageFormatException ($"Invalid TypeMap class name '{key}'."); + } + keys.Add (key); } } static int GetArgumentCount (MetadataReader reader, CustomAttribute attribute) { BlobHandle signature; + StringHandle constructorName; if (attribute.Constructor.Kind == HandleKind.MemberReference) { - signature = reader.GetMemberReference ((MemberReferenceHandle) attribute.Constructor).Signature; + var constructor = reader.GetMemberReference ((MemberReferenceHandle) attribute.Constructor); + signature = constructor.Signature; + constructorName = constructor.Name; } else { - signature = reader.GetMethodDefinition ((MethodDefinitionHandle) attribute.Constructor).Signature; + var constructor = reader.GetMethodDefinition ((MethodDefinitionHandle) attribute.Constructor); + signature = constructor.Signature; + constructorName = constructor.Name; } var blob = reader.GetBlobReader (signature); var header = blob.ReadSignatureHeader (); int count = blob.ReadCompressedInteger (); - if (header.Kind != SignatureKind.Method || !header.IsInstance || header.IsGeneric || + if (reader.GetString (constructorName) != ".ctor" || header.Kind != SignatureKind.Method || !header.IsInstance || header.IsGeneric || (count != 2 && count != 3) || blob.ReadSignatureTypeCode () != SignatureTypeCode.Void || blob.ReadSignatureTypeCode () != SignatureTypeCode.String) { throw new BadImageFormatException ("Invalid TypeMap constructor signature."); diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromAssembliesTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromAssembliesTests.cs index 287bdbd7063..e3902702ac3 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromAssembliesTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromAssembliesTests.cs @@ -79,14 +79,8 @@ public void UnionsAllAssembliesAndRidsWithExactCanonicalEncoding () [InlineData ("test/A[123]", "test/A")] [InlineData ("test/A[000]", "test/A")] [InlineData ("test/A[9999999999999999999999999999999]", "test/A")] - [InlineData ("test/A[]", "test/A[]")] - [InlineData ("test/A[-1]", "test/A[-1]")] - [InlineData ("test/A[1x]", "test/A[1x]")] - [InlineData ("test/A[1]Extra", "test/A[1]Extra")] - [InlineData ("test/A[1", "test/A[1")] - [InlineData ("test/A[", "test/A[")] - [InlineData ("[0]", "[0]")] - [InlineData ("test/A[\u0661]", "test/A[\u0661]")] + [InlineData ("test/Outer$Inner[2]", "test/Outer$Inner")] + [InlineData ("test/\u2160[1]", "test/\u2160")] public void NormalizesOnlyFinalNonemptyAsciiDecimalAlias (string key, string expected) { var (task, _) = CreateTask (Emit ("Map", Entry (key))); @@ -97,23 +91,28 @@ public void NormalizesOnlyFinalNonemptyAsciiDecimalAlias (string key, string exp [Fact] public void EmptyStubsAndOrdinaryManagedAssembliesContributeNoKeys () { - var pe = new PEAssemblyBuilder (RuntimeVersion); - pe.EmitPreamble ("Stub", "Stub.dll"); - string stub = Path.Combine (directory, "Stub.dll"); - using (var stream = File.Create (stub)) { - pe.WritePE (stream); - } - var (task, _) = CreateTask (stub, typeof (ExtractTypeMapKeysFromAssembliesTests).Assembly.Location); + string stub = EmitStub ("Stub"); + string fixtures = Path.Combine (AppContext.BaseDirectory, "TestFixtures.dll"); + var (task, _) = CreateTask (stub, fixtures); Assert.True (task.Execute ()); Assert.Empty (File.ReadAllBytes (task.OutputFile)); task.LinkedAssemblies = [new TaskItem (Emit ("Map", Entry ("test/Present")))]; Assert.True (task.Execute ()); - task.LinkedAssemblies = []; + task.LinkedAssemblies = [new TaskItem (stub)]; Assert.True (task.Execute ()); Assert.Empty (File.ReadAllBytes (task.OutputFile)); } + [Fact] + public void MissingAssemblyInputsFail () + { + var (task, engine) = CreateTask (); + Assert.False (task.Execute ()); + Assert.Contains (engine.Errors, e => e.Code == "XA4327"); + Assert.False (File.Exists (task.OutputFile)); + } + [Fact] public void IgnoresAliasArraysAssociationsAssemblyTargetsAndUnrelatedAttributes () { @@ -142,12 +141,15 @@ public void IgnoresAliasArraysAssociationsAssemblyTargetsAndUnrelatedAttributes [Theory] [InlineData ("missing")] [InlineData ("malformed")] + [InlineData ("zero-byte")] [InlineData ("directory")] public void BadRequiredAssemblyFailsWithoutWritingOutput (string kind) { string path = Path.Combine (directory, "bad.dll"); if (kind == "malformed") { File.WriteAllBytes (path, [1, 2, 3]); + } else if (kind == "zero-byte") { + File.WriteAllBytes (path, []); } else if (kind == "directory") { Directory.CreateDirectory (path); } @@ -162,6 +164,21 @@ public void BadRequiredAssemblyFailsWithoutWritingOutput (string kind) [InlineData ("test/Invalid\nName")] [InlineData ("test/Invalid\rName")] [InlineData ("test/Invalid\0Name")] + [InlineData ("test/A[]")] + [InlineData ("test/A[-1]")] + [InlineData ("test/A[1x]")] + [InlineData ("test/A[1]Extra")] + [InlineData ("test/A[1")] + [InlineData ("test/A[")] + [InlineData ("[0]")] + [InlineData ("test/A[\u0661]")] + [InlineData ("test/A[1][2]")] + [InlineData ("test.Invalid")] + [InlineData ("test/*")] + [InlineData ("test/")] + [InlineData ("/test")] + [InlineData ("test//Invalid")] + [InlineData ("test/Invalid\u200bName")] public void InvalidKeyFails (string key) { var (task, engine) = CreateTask (Emit ("Map", Entry (key))); @@ -169,6 +186,25 @@ public void InvalidKeyFails (string key) Assert.Contains (engine.Errors, e => e.Code == "XA4327"); } + [Fact] + public void NetmoduleIsNotAValidAssemblyInput () + { + var metadata = new MetadataBuilder (); + metadata.AddModule (0, metadata.GetOrAddString ("Invalid.netmodule"), metadata.GetOrAddGuid (Guid.NewGuid ()), default, default); + metadata.AddTypeDefinition (default, default, metadata.GetOrAddString (""), default, + MetadataTokens.FieldDefinitionHandle (1), MetadataTokens.MethodDefinitionHandle (1)); + var image = new BlobBuilder (); + new ManagedPEBuilder (new PEHeaderBuilder (imageCharacteristics: Characteristics.Dll), + new MetadataRootBuilder (metadata), new BlobBuilder ()).Serialize (image); + string path = Path.Combine (directory, "Invalid.netmodule"); + using (var stream = File.Create (path)) { + image.WriteContentTo (stream); + } + var (task, engine) = CreateTask (path); + Assert.False (task.Execute ()); + Assert.Contains (engine.Errors, e => e.Code == "XA4327"); + } + [Fact] public void OutputWriteFailureIsReported () { @@ -178,9 +214,93 @@ public void OutputWriteFailureIsReported () Assert.Contains (engine.Errors, e => e.Code == "XA4327"); } + [Theory] + [InlineData ("prolog")] + [InlineData ("null-key")] + [InlineData ("null-type")] + [InlineData ("missing-type")] + [InlineData ("named-arguments")] + [InlineData ("trailing-bytes")] + [InlineData ("argument-count")] + [InlineData ("key-type")] + [InlineData ("type-parameter")] + [InlineData ("static-method")] + [InlineData ("method-name")] + public void MalformedTypeMapAttributeFails (string defect) + { + var (task, engine) = CreateTask (EmitAttribute (defect)); + Assert.False (task.Execute ()); + Assert.Contains (engine.Errors, e => e.Code == "XA4327"); + Assert.False (File.Exists (task.OutputFile)); + } + + [Theory] + [InlineData ("type-attribute")] + [InlineData ("other-namespace")] + public void OnlyAssemblyTypeMapAttributesAreEntries (string kind) + { + var (task, _) = CreateTask (EmitAttribute (kind)); + Assert.True (task.Execute ()); + Assert.Empty (File.ReadAllBytes (task.OutputFile)); + } + + string EmitAttribute (string kind) + { + var pe = new PEAssemblyBuilder (RuntimeVersion); + pe.EmitPreamble ("Attributes", "Attributes.dll"); + var metadata = pe.Metadata; + var systemType = metadata.AddTypeReference (pe.SystemRuntimeRef, + metadata.GetOrAddString ("System"), metadata.GetOrAddString ("Type")); + var openAttribute = metadata.AddTypeReference (pe.SystemRuntimeInteropServicesRef, + metadata.GetOrAddString (kind == "other-namespace" ? "Unrelated" : "System.Runtime.InteropServices"), + metadata.GetOrAddString ("TypeMapAttribute`1")); + var attributeType = pe.MakeGenericTypeSpec (openAttribute, systemType); + var constructor = pe.AddMemberRef (attributeType, kind == "method-name" ? "NotAConstructor" : ".ctor", s => + s.MethodSignature (isInstanceMethod: kind != "static-method").Parameters (kind == "argument-count" ? 1 : 2, + r => r.Void (), p => { + if (kind == "key-type") { + p.AddParameter ().Type ().Int32 (); + } else { + p.AddParameter ().Type ().String (); + } + if (kind != "argument-count") { + if (kind == "type-parameter") { + p.AddParameter ().Type ().String (); + } else { + p.AddParameter ().Type ().Type (systemType, isValueType: false); + } + } + })); + var value = new BlobBuilder (); + value.WriteUInt16 ((ushort) (kind == "prolog" ? 2 : 1)); + value.WriteSerializedString (kind == "null-key" ? null : "test/Key"); + if (kind != "missing-type") { + value.WriteSerializedString (kind == "null-type" ? null : "System.Object, System.Runtime"); + } + value.WriteUInt16 ((ushort) (kind == "named-arguments" ? 1 : 0)); + if (kind == "trailing-bytes") { + value.WriteByte (42); + } + EntityHandle parent = EntityHandle.AssemblyDefinition; + if (kind == "type-attribute") { + parent = metadata.AddTypeDefinition (TypeAttributes.Public, default, metadata.GetOrAddString ("NotAnAssembly"), + default, MetadataTokens.FieldDefinitionHandle (1), MetadataTokens.MethodDefinitionHandle (1)); + } + metadata.AddCustomAttribute (parent, constructor, metadata.GetOrAddBlob (value)); + string path = Path.Combine (directory, "Attributes.dll"); + using var stream = File.Create (path); + pe.WritePE (stream); + return path; + } + [Fact] public async Task RealILLinkRetainsOnlyLiveTypeMapAttributes () { + // The emitter references these assemblies in unused MemberRefs even when the + // fixture contains no Android proxy IL. Resolve them without skipping errors. + EmitStub ("Mono.Android"); + EmitStub ("Java.Interop"); + EmitStub ("Mono.Android.Runtime"); EmitTargets (); string input = Emit ("_Bindings.TypeMap", Entry ("test/Unconditional"), @@ -218,6 +338,14 @@ public async Task RealILLinkRetainsOnlyLiveTypeMapAttributes () Assert.Equal (new [] { "test/Alias[0]", "test/Surviving", "test/Unconditional" }, keys.OrderBy (k => k, StringComparer.Ordinal)); } + string EmitStub (string name) + { + string path = Path.Combine (directory, name + ".dll"); + using var stream = File.Create (path); + new TypeMapAssemblyGenerator (RuntimeVersion).GenerateEmpty (stream, name); + return path; + } + void EmitTargets () { var pe = new PEAssemblyBuilder (RuntimeVersion); @@ -284,7 +412,7 @@ async Task RunLinker (string root, string output) foreach (string arg in new [] { "exec", "--fx-version", Path.GetFileName (runtimeDirectory), linker, "-a", Path.GetFileNameWithoutExtension (root), "-reference", root, "-d", directory, "-d", runtimeDirectory, "-out", output, - "--typemap-entry-assembly", "Root", "--skip-unresolved", "true", + "--typemap-entry-assembly", "Root", "--skip-unresolved", "false", }) { start.ArgumentList.Add (arg); } diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests.csproj b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests.csproj index aff79f1357e..1d79fa112e6 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests.csproj +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests.csproj @@ -51,6 +51,7 @@ + From 64ef11d87de4072aa5d23096280a93ec5361edbb Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 18 Sep 2026 09:12:28 +0200 Subject: [PATCH 10/25] Move retained-key ProGuard generator to modern build tasks Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tasks/GenerateTypeMapProguardConfiguration.cs | 3 ++- .../GenerateTypeMapProguardConfigurationTests.cs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) rename src/{Xamarin.Android.Build.Tasks => Microsoft.Android.Build.Tasks}/Tasks/GenerateTypeMapProguardConfiguration.cs (97%) rename src/{Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks => Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests}/GenerateTypeMapProguardConfigurationTests.cs (99%) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTypeMapProguardConfiguration.cs b/src/Microsoft.Android.Build.Tasks/Tasks/GenerateTypeMapProguardConfiguration.cs similarity index 97% rename from src/Xamarin.Android.Build.Tasks/Tasks/GenerateTypeMapProguardConfiguration.cs rename to src/Microsoft.Android.Build.Tasks/Tasks/GenerateTypeMapProguardConfiguration.cs index d9b9063ef50..65bdaf88d56 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTypeMapProguardConfiguration.cs +++ b/src/Microsoft.Android.Build.Tasks/Tasks/GenerateTypeMapProguardConfiguration.cs @@ -5,8 +5,9 @@ using System.Text; using Microsoft.Android.Build.Tasks; using Microsoft.Build.Framework; +using Properties = Xamarin.Android.Tasks.Properties; -namespace Xamarin.Android.Tasks; +namespace Microsoft.Android.Tasks; public class GenerateTypeMapProguardConfiguration : AndroidTask { diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTypeMapProguardConfigurationTests.cs b/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/GenerateTypeMapProguardConfigurationTests.cs similarity index 99% rename from src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTypeMapProguardConfigurationTests.cs rename to src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/GenerateTypeMapProguardConfigurationTests.cs index 579ffcd30e6..df4a52ec9ff 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTypeMapProguardConfigurationTests.cs +++ b/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/GenerateTypeMapProguardConfigurationTests.cs @@ -3,7 +3,7 @@ using System.Text; using Microsoft.Build.Utilities; using NUnit.Framework; -using Xamarin.Android.Tasks; +using Microsoft.Android.Tasks; namespace Xamarin.Android.Build.Tests; From 679e13acf666cbf53f44756e31abc6e7214356fd Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 18 Sep 2026 09:11:33 +0200 Subject: [PATCH 11/25] Move DGML typemap adapter to modern Android build tasks Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tasks/ExtractTypeMapKeysFromDgml.cs | 4 +++- .../Generator/ExtractTypeMapKeysFromDgmlTests.cs | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) rename src/{Xamarin.Android.Build.Tasks => Microsoft.Android.Build.Tasks}/Tasks/ExtractTypeMapKeysFromDgml.cs (98%) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromDgml.cs b/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromDgml.cs similarity index 98% rename from src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromDgml.cs rename to src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromDgml.cs index 20adc2036df..6fb1b0cd981 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromDgml.cs +++ b/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromDgml.cs @@ -8,8 +8,10 @@ using System.Xml; using Microsoft.Build.Framework; using Microsoft.Android.Build.Tasks; +using Xamarin.Android.Tasks; +using Properties = Xamarin.Android.Tasks.Properties; -namespace Xamarin.Android.Tasks; +namespace Microsoft.Android.Tasks; public class ExtractTypeMapKeysFromDgml : AndroidTask { diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromDgmlTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromDgmlTests.cs index 0f4793ebdbf..a97420f819a 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromDgmlTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromDgmlTests.cs @@ -3,9 +3,9 @@ using System.IO; using System.Linq; using System.Text; +using Microsoft.Android.Tasks; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; -using Xamarin.Android.Tasks; using Xunit; namespace Microsoft.Android.Sdk.TrimmableTypeMap.Tests; From 36a06f55e0cd886e2f3c816aa9ef82c65022d394 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 18 Sep 2026 09:12:30 +0200 Subject: [PATCH 12/25] Move LLVM typemap extraction to modern Android tasks Relocate ExtractTypeMapKeysFromLlvmIr to Microsoft.Android.Build.Tasks and use its source-linked shared diagnostics. Move the emitter-backed NUnit fixture to the modern task test project using its existing aliased reference to the LLVM emitter. Preserve the task API, key format, and parser behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tasks/ExtractTypeMapKeysFromLlvmIr.cs | 4 +++- .../ExtractTypeMapKeysFromLlvmIrTests.cs | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) rename src/{Xamarin.Android.Build.Tasks => Microsoft.Android.Build.Tasks}/Tasks/ExtractTypeMapKeysFromLlvmIr.cs (98%) rename src/{Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks => Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests}/ExtractTypeMapKeysFromLlvmIrTests.cs (98%) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromLlvmIr.cs b/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromLlvmIr.cs similarity index 98% rename from src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromLlvmIr.cs rename to src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromLlvmIr.cs index 792e428cdc7..2f6fa8be448 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromLlvmIr.cs +++ b/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromLlvmIr.cs @@ -5,8 +5,10 @@ using System.Text; using Microsoft.Android.Build.Tasks; using Microsoft.Build.Framework; +using Xamarin.Android.Tasks; +using Properties = Xamarin.Android.Tasks.Properties; -namespace Xamarin.Android.Tasks; +namespace Microsoft.Android.Tasks; public class ExtractTypeMapKeysFromLlvmIr : AndroidTask { diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/ExtractTypeMapKeysFromLlvmIrTests.cs b/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/ExtractTypeMapKeysFromLlvmIrTests.cs similarity index 98% rename from src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/ExtractTypeMapKeysFromLlvmIrTests.cs rename to src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/ExtractTypeMapKeysFromLlvmIrTests.cs index 4a2c21aab11..62c2562f463 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/ExtractTypeMapKeysFromLlvmIrTests.cs +++ b/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/ExtractTypeMapKeysFromLlvmIrTests.cs @@ -1,17 +1,19 @@ +extern alias xamarinbuildtasks; + using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Text; +using Microsoft.Android.Tasks; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; using NUnit.Framework; -using Xamarin.Android.Tasks; -using Xamarin.Android.Tasks.LLVMIR; +using xamarinbuildtasks::Xamarin.Android.Tasks.LLVMIR; using Xamarin.Android.Tools; -namespace Xamarin.Android.Build.Tests.Tasks; +namespace Xamarin.Android.Build.Tests; [TestFixture] public class ExtractTypeMapKeysFromLlvmIrTests From dfcb9c2ecc9858250c845b153cf7c1044306259c Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 18 Sep 2026 09:13:08 +0200 Subject: [PATCH 13/25] Move linked typemap extraction into modern Android build tasks Link existing metadata helpers into the modern task assembly and test its production binaries directly instead of source-linking task copies. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Microsoft.Android.Build.Tasks.csproj | 3 +++ .../Tasks/ExtractTypeMapKeysFromAssemblies.cs | 4 +++- .../ExtractTypeMapKeysFromAssembliesTests.cs | 1 + ...rosoft.Android.Sdk.TrimmableTypeMap.Tests.csproj | 13 +------------ 4 files changed, 8 insertions(+), 13 deletions(-) rename src/{Xamarin.Android.Build.Tasks => Microsoft.Android.Build.Tasks}/Tasks/ExtractTypeMapKeysFromAssemblies.cs (98%) diff --git a/src/Microsoft.Android.Build.Tasks/Microsoft.Android.Build.Tasks.csproj b/src/Microsoft.Android.Build.Tasks/Microsoft.Android.Build.Tasks.csproj index d4949cffaec..5b8d17b5792 100644 --- a/src/Microsoft.Android.Build.Tasks/Microsoft.Android.Build.Tasks.csproj +++ b/src/Microsoft.Android.Build.Tasks/Microsoft.Android.Build.Tasks.csproj @@ -12,6 +12,7 @@ $(DotNetTargetFramework) Microsoft.Android.Tasks enable + true Nullable @@ -31,7 +30,7 @@ - + false @@ -47,17 +46,7 @@ - - - - - - - - - - From 96bf37622fe4326f305eb372fb86c48855cdb82b Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 18 Sep 2026 09:14:03 +0200 Subject: [PATCH 14/25] Assert DGML adapter is loaded from the modern task assembly Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Generator/ExtractTypeMapKeysFromDgmlTests.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromDgmlTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromDgmlTests.cs index a97420f819a..73eabcfb14b 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromDgmlTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromDgmlTests.cs @@ -28,6 +28,13 @@ public class ExtractTypeMapKeysFromDgmlTests : IDisposable """; + [Fact] + public void TaskLivesInModernBuildTasksAssembly () + { + Assert.Equal ("Microsoft.Android.Tasks.ExtractTypeMapKeysFromDgml", typeof (ExtractTypeMapKeysFromDgml).FullName); + Assert.Equal ("Microsoft.Android.Build.Tasks", typeof (ExtractTypeMapKeysFromDgml).Assembly.GetName ().Name); + } + [Fact] public void Execute_UnionsGraphsAndMatchesOnlyQualifiedMetadata () { From 03ca398d238399221cda09491bd149ea8bfb66e7 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 18 Sep 2026 09:16:51 +0200 Subject: [PATCH 15/25] Drive R8 class shrinking from retained typemap keys Wire the selected modern adapter after its final producer, union per-RID inputs, generate class-only roots, and keep member retention and non-obfuscation policy separate. Track source lists and mode changes while preserving legacy and explicit override paths. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Documentation/guides/D8andR8.md | 61 ++++ ...oft.Android.Sdk.AssemblyResolution.targets | 18 + ...crosoft.Android.Sdk.TypeMap.LlvmIr.targets | 4 +- ...osoft.Android.Sdk.TypeMap.Proguard.targets | 103 ++++++ ...roid.Sdk.TypeMap.Trimmable.CoreCLR.targets | 4 +- ...id.Sdk.TypeMap.Trimmable.NativeAOT.targets | 89 +---- .../proguard_trimmable_nativeaot.cfg | 21 -- .../Resources/proguard_typemap.cfg | 38 ++ .../GenerateNativeAotProguardConfiguration.cs | 139 -------- src/Xamarin.Android.Build.Tasks/Tasks/R8.cs | 23 +- ...rateNativeAotProguardConfigurationTests.cs | 128 ------- .../Tasks/R8Tests.cs | 47 +++ .../TrimmableTypeMapBuildTests.cs | 63 +++- .../Xamarin.Android.Common.targets | 11 +- .../Xamarin.Android.D8.targets | 4 +- .../Generator/TypeMapProguardTests.cs | 332 ++++++++++++++++++ 16 files changed, 690 insertions(+), 395 deletions(-) create mode 100644 src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Proguard.targets delete mode 100644 src/Xamarin.Android.Build.Tasks/Resources/proguard_trimmable_nativeaot.cfg create mode 100644 src/Xamarin.Android.Build.Tasks/Resources/proguard_typemap.cfg delete mode 100644 src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeAotProguardConfiguration.cs delete mode 100644 src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateNativeAotProguardConfigurationTests.cs create mode 100644 tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs diff --git a/Documentation/guides/D8andR8.md b/Documentation/guides/D8andR8.md index b44b1ea04a8..c3b9df206ee 100644 --- a/Documentation/guides/D8andR8.md +++ b/Documentation/guides/D8andR8.md @@ -1,5 +1,66 @@ This is the D8 and R8 integration specification for .NET for Android. +# Retained typemap class roots + +When managed trimming and R8 are enabled, CoreCLR and trimmable NativeAOT +builds derive Java class roots from the final typemap rather than retaining +every class in the ACW map: + +| Runtime and typemap | Retained-key source | +| --- | --- | +| NativeAOT, trimmable | ILC type-metadata nodes joined to the ACW map | +| CoreCLR, LLVM IR | The generated LLVM Java-name blob, after `GenerateTypeMappings` | +| CoreCLR, trimmable | Surviving `TypeMapAttribute` records in linked typemap assemblies, including empty stubs | + +Inner builds return their exact source paths to the outer build. Keys are +unioned across the requested RIDs/ABIs; stale files from other builds are not +discovered by globbing. NativeAOT uses the scan graph in optimized builds and +the codegen graph when the scanner is disabled. Its existing Java-trimming +opt-out instead extracts every ACW class without requiring a graph. + +All three adapters and the shared generator are `Microsoft.Android.Tasks` +tasks in `Microsoft.Android.Build.Tasks.dll`. +The adapters write `typemap.keys.txt` in the outer intermediate directory. +The format is UTF-8 without a BOM, one canonical JNI class name per line, +ordinal-sorted and distinct, with LF endings and a final LF when nonempty. +For example: + +```text +android/app/Activity +example/Outer$Inner +``` + +The DLL adapter collapses implementation-specific numeric aliases before +writing this format. A valid zero-byte file means no retained classes; +missing, unreadable, or unsupported inputs fail the build. + +`GenerateTypeMapProguardConfiguration` knows only this format. It accepts a +union of keys files and writes deterministic class-only rules to +`proguard/proguard_project_references.cfg`: + +```text +-keep class android.app.Activity +-keep class example.Outer$Inner +``` + +The generator never emits member rules or global R8 options. The separate +`proguard_typemap.cfg` runtime configuration retains members of surviving +classes, including surviving third-party classes, while allowing unused +classes to disappear. It keeps explicit runtime bootstrap roots rather than +whole wrapper packages. User Java source retention and application/library +ProGuard rules remain separate. + +This pipeline disables all obfuscation, including private-member +obfuscation, with both `-dontobfuscate` and R8's `--no-minification` option. +MonoVM, nonshrinking/multidex-only builds, and the existing complete +`ProguardConfigFiles` override keep their previous behavior. + +Both extraction and rule generation use their real files as incremental +outputs. A content-sensitive input manifest also tracks source-list and +runtime/trim-policy changes, so removing a RID or switching modes cannot +reuse stale class roots. The outputs are registered in `FileWrites` and +regenerated if deleted. + # What is D8? What is R8? At a high level, here are the steps that occur during an Android 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 2b056f02870..60c721e2b76 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 @@ -74,12 +74,30 @@ _ResolveAssemblies MSBuild target. Returns="@(ResolvedFileToPublish)"> + + + + <_AndroidLinkedTypeMapKeySource Include="@(_GeneratedTypeMapAssembliesFromList->'$(IntermediateOutputPath)linked\%(Filename)%(Extension)')" + Condition=" '$(_AndroidRuntime)' == 'CoreCLR' and '$(AndroidTypeMapImplementation)' == 'trimmable' and '$(PublishTrimmed)' == 'true' " /> + + + + + diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.LlvmIr.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.LlvmIr.targets index 00f25c4954e..0401720a68d 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.LlvmIr.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.LlvmIr.targets @@ -446,8 +446,8 @@ + + + + + + + + <_CompileToDalvikDependsOnTargets>$(_CompileToDalvikDependsOnTargets);_AndroidGenerateTypeMapProguardConfiguration + + + + + <_ProguardProjectConfiguration>$(IntermediateOutputPath)proguard\proguard_project_references.cfg + <_AndroidTypeMapProguardConfiguration>$(_ProguardProjectConfiguration) + + <_AndroidUseTypeMapProguardConfiguration Condition=" '$(ProguardConfigFiles)' == '' and ('$(_AndroidRuntime)' != 'NativeAOT' or '$(_UseTrimmableNativeAotProguardConfiguration)' != 'false') ">true + + + + + + <_AndroidTypeMapKeysFile>$(IntermediateOutputPath)typemap.keys.txt + <_AndroidTypeMapKeysInputsFile>$(IntermediateOutputPath)typemap.keys.inputs + <_AndroidTypeMapKeysKind Condition=" '$(_AndroidRuntime)' == 'NativeAOT' ">dgml + <_AndroidTypeMapKeysKind Condition=" '$(_AndroidRuntime)' == 'CoreCLR' and '$(AndroidTypeMapImplementation)' != 'trimmable' ">llvm-ir + <_AndroidTypeMapKeysKind Condition=" '$(_AndroidRuntime)' == 'CoreCLR' and '$(AndroidTypeMapImplementation)' == 'trimmable' ">assemblies + <_AndroidTypeMapLinkedAssemblies>@(ResolvedFileToPublish->'%(AndroidTypeMapLinkedAssemblies)'->Distinct()) + + + <_AndroidTypeMapKeySource Remove="@(_AndroidTypeMapKeySource)" /> + <_AndroidTypeMapDgmlSource Remove="@(_AndroidTypeMapDgmlSource)" /> + + <_AndroidTypeMapDgmlSource Include="@(ResolvedFileToPublish->'%(AndroidTypeMapDgmlFile)'->Distinct())" + Condition=" '$(_AndroidTypeMapKeysKind)' == 'dgml' and '$(_AndroidTrimmableTypemapTrimJavaCode)' == 'true' " /> + <_AndroidTypeMapKeySource Include="@(_AndroidTypeMapDgmlSource);$(_AcwMapFile)" + Condition=" '$(_AndroidTypeMapKeysKind)' == 'dgml' " /> + <_AndroidTypeMapKeySource Include="@(_TypeMapAssemblySource->Distinct())" + Condition=" '$(_AndroidTypeMapKeysKind)' == 'llvm-ir' " /> + <_AndroidTypeMapKeySource Include="$(_AndroidTypeMapLinkedAssemblies)" + Condition=" '$(_AndroidTypeMapKeysKind)' == 'assemblies' " /> + <_AndroidTypeMapKeysState Remove="@(_AndroidTypeMapKeysState)" /> + <_AndroidTypeMapKeysState Include="Runtime=$(_AndroidRuntime);Representation=$(_AndroidTypeMapKeysKind);TrimJava=$(_AndroidTrimmableTypemapTrimJavaCode);Optimize=$(Optimize);RuntimeIdentifier=$(RuntimeIdentifier);RuntimeIdentifiers=$(RuntimeIdentifiers);TypemapPolicy=$(_AndroidUseTypeMapProguardConfiguration)" /> + <_AndroidTypeMapKeysState Include="@(_AndroidTypeMapKeySource->'%(FullPath)')" /> + + + + + + + + + + + + + + + + + + + + <_AndroidTypeMapKeyFile Remove="@(_AndroidTypeMapKeyFile)" /> + <_AndroidTypeMapKeyFile Include="$(_AndroidTypeMapKeysFile)" /> + + + + + + + + + + diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets index 5dd6a30bb97..c61aeaf580c 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets @@ -43,8 +43,8 @@ - - @@ -24,8 +22,6 @@ True True <_UseTrimmableNativeAotProguardConfiguration Condition=" '$(_UseTrimmableNativeAotProguardConfiguration)' == '' ">true - <_TrimmableNativeAotProguardConfigurationInputsStamp>$(_AndroidStampDirectory)_GenerateTrimmableTypeMapProguardConfiguration.inputs - <_CompileToDalvikDependsOnTargets>$(_CompileToDalvikDependsOnTargets);_GenerateTrimmableTypeMapProguardConfiguration - <_TrimmableNativeAotRuntimeIdentifiers Include="$(RuntimeIdentifier)" Condition=" '$(RuntimeIdentifiers)' == '' and '$(RuntimeIdentifier)' != '' " /> - <_TrimmableNativeAotRuntimeIdentifiers Include="$(RuntimeIdentifiers)" Condition=" '$(RuntimeIdentifiers)' != '' " /> - <_TrimmableNativeAotSiblingRuntimeIdentifiers Include="@(_TrimmableNativeAotRuntimeIdentifiers)" /> - <_TrimmableNativeAotSiblingRuntimeIdentifiers Remove="$(RuntimeIdentifier)" /> - - <_TrimmableNativeAotScanDgmlCandidates Include="$(NativeIntermediateOutputPath)$(TargetName).scan.dgml.xml" /> - <_TrimmableNativeAotScanDgmlCandidates - Include="@(_TrimmableNativeAotRuntimeIdentifiers->'$(IntermediateOutputPath)%(Identity)\native\$(TargetName).scan.dgml.xml')" /> - <_TrimmableNativeAotScanDgmlCandidates - Include="@(_TrimmableNativeAotSiblingRuntimeIdentifiers->'$(IntermediateOutputPath)..\%(Identity)\native\$(TargetName).scan.dgml.xml')" - Condition=" '$(RuntimeIdentifier)' != '' " /> - <_TrimmableNativeAotScanDgmlCandidates Remove="@(_TrimmableNativeAotScanDgmlCandidates)" - Condition=" !Exists('%(_TrimmableNativeAotScanDgmlCandidates.Identity)') " /> - <_TrimmableNativeAotDgmlFiles Include="@(_TrimmableNativeAotScanDgmlCandidates)" /> - - <_TrimmableNativeAotCodegenDgmlCandidates Include="$(NativeIntermediateOutputPath)$(TargetName).codegen.dgml.xml" /> - <_TrimmableNativeAotCodegenDgmlCandidates - Include="@(_TrimmableNativeAotRuntimeIdentifiers->'$(IntermediateOutputPath)%(Identity)\native\$(TargetName).codegen.dgml.xml')" /> - <_TrimmableNativeAotCodegenDgmlCandidates - Include="@(_TrimmableNativeAotSiblingRuntimeIdentifiers->'$(IntermediateOutputPath)..\%(Identity)\native\$(TargetName).codegen.dgml.xml')" - Condition=" '$(RuntimeIdentifier)' != '' " /> - <_TrimmableNativeAotCodegenDgmlCandidates Remove="@(_TrimmableNativeAotCodegenDgmlCandidates)" - Condition=" !Exists('%(_TrimmableNativeAotCodegenDgmlCandidates.Identity)') " /> - <_TrimmableNativeAotCodegenDgmlFiles Include="@(_TrimmableNativeAotCodegenDgmlCandidates)" /> - <_TrimmableNativeAotDgmlFiles Include="@(_TrimmableNativeAotCodegenDgmlFiles)" - Condition=" '@(_TrimmableNativeAotDgmlFiles->Count())' == '0' " /> - - - - - - - - - - - - - - - - - - diff --git a/src/Xamarin.Android.Build.Tasks/Resources/proguard_trimmable_nativeaot.cfg b/src/Xamarin.Android.Build.Tasks/Resources/proguard_trimmable_nativeaot.cfg deleted file mode 100644 index 8c53ede5e9c..00000000000 --- a/src/Xamarin.Android.Build.Tasks/Resources/proguard_trimmable_nativeaot.cfg +++ /dev/null @@ -1,21 +0,0 @@ -# Xamarin.Android NativeAOT trimmable typemap configuration. - --keep class net.dot.jni.** { *; (...); } -# Loaded by name from managed code. --keep class net.dot.android.ApplicationRegistration { *; } --keep class net.dot.android.crypto.** { *; (...); } -# NativeAOT resolves these interface methods through JNI during startup. --keep class mono.android.IGCUserPeer { *; } -# Native hosts resolve these package-private fields by name during startup. --keepclassmembers class mono.android.Runtime { - static java.lang.Class *; -} - --keepclassmembers class * extends android.view.View { - *** set*(...); -} - --keepclassmembers class * extends android.view.View { - (android.content.Context,android.util.AttributeSet); - (android.content.Context,android.util.AttributeSet,int); -} diff --git a/src/Xamarin.Android.Build.Tasks/Resources/proguard_typemap.cfg b/src/Xamarin.Android.Build.Tasks/Resources/proguard_typemap.cfg new file mode 100644 index 00000000000..a8c120c0947 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Resources/proguard_typemap.cfg @@ -0,0 +1,38 @@ +# Class-only shrinking for the retained typemap pipeline. JNI member liveness +# is deliberately independent of the generated class roots. +-keepclassmembers class * { + *; +} + +# Java.Interop resolves these support classes by name. +-keep class net.dot.jni.ManagedPeer +-keep interface net.dot.jni.GCUserPeerable +-keep class net.dot.jni.internal.JavaProxyObject +-keep class net.dot.jni.internal.JavaProxyThrowable +# Manifest providers and their runtime bootstrap entry points. +-keep class net.dot.jni.nativeaot.NativeAotRuntimeProvider +-keep class net.dot.jni.nativeaot.JavaInteropRuntime +-keep class net.dot.jni.nativeaot.NativeAotEnvironmentVars +-keep class mono.MonoRuntimeProvider* +-keep class mono.MonoPackageManager +-keep class mono.MonoPackageManager_Resources +# Native hosts resolve Runtime and its GC peer interfaces by name. +-keep class mono.android.Runtime +-keep interface mono.android.IGCUserPeer +-keep class mono.android.GCUserPeer +# Loaded by name from managed code. +-keep class net.dot.android.ApplicationRegistration { *; } +-keep class net.dot.android.crypto.** { *; (...); } +# Native hosts resolve these package-private fields by name during startup. +-keepclassmembers class mono.android.Runtime { + static java.lang.Class *; +} + +-keepclassmembers class * extends android.view.View { + *** set*(...); +} + +-keepclassmembers class * extends android.view.View { + (android.content.Context,android.util.AttributeSet); + (android.content.Context,android.util.AttributeSet,int); +} diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeAotProguardConfiguration.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeAotProguardConfiguration.cs deleted file mode 100644 index b8369374be9..00000000000 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeAotProguardConfiguration.cs +++ /dev/null @@ -1,139 +0,0 @@ -#nullable enable - -using System; -using System.Collections.Generic; -using System.IO; -using System.Xml; -using Microsoft.Build.Framework; -using Microsoft.Android.Build.Tasks; - -namespace Xamarin.Android.Tasks; - -public class GenerateNativeAotProguardConfiguration : AndroidTask -{ - const string TypeMetadataPrefix = "Type metadata: ["; - - public override string TaskPrefix => "GNAPC"; - - public ITaskItem [] NativeAotDgmlFiles { get; set; } = []; - - [Required] - public string AcwMapFile { get; set; } = ""; - - [Required] - public string OutputFile { get; set; } = ""; - - // When false, the ILC DGML is not consulted (it may not have been generated at all) and a - // -keep rule is emitted for every Java type in the ACW map, so R8 keeps them all instead of - // shrinking the unused ones. Large binding closures can add several MB of compressed DEX, but - // this avoids generating and processing the very large ILC dependency graph. - public bool TrimJavaCallableWrappers { get; set; } = true; - - public override bool RunTask () - { - var dir = Path.GetDirectoryName (OutputFile); - if (!dir.IsNullOrEmpty () && !Directory.Exists (dir)) { - Directory.CreateDirectory (dir); - } - - if (!File.Exists (AcwMapFile)) { - Log.LogCodedError ("XA4320", Properties.Resources.XA4320, AcwMapFile); - return !Log.HasLoggedErrors; - } - - HashSet? retainedTypeKeys = null; - if (TrimJavaCallableWrappers) { - if (NativeAotDgmlFiles.Length == 0) { - Log.LogCodedError ("XA4319", Properties.Resources.XA4319); - return !Log.HasLoggedErrors; - } - foreach (var dgmlFile in NativeAotDgmlFiles) { - if (!File.Exists (dgmlFile.ItemSpec)) { - Log.LogCodedError ("XA4321", Properties.Resources.XA4321, dgmlFile.ItemSpec); - return !Log.HasLoggedErrors; - } - } - retainedTypeKeys = LoadRetainedTypeKeysFromDgml (); - } - - // A null retainedTypeKeys means "keep every Java type in the ACW map" (Java trimming disabled). - var javaTypes = LoadJavaTypesFromAcwMap (retainedTypeKeys); - - using var writer = new StringWriter (); - writer.WriteLine ("# ACWs retained by NativeAOT ILC"); - foreach (var javaTypeName in javaTypes) { - writer.WriteLine ($"-keep class {javaTypeName} {{ *; }}"); - } - Files.CopyIfStringChanged (writer.ToString (), OutputFile); - - if (TrimJavaCallableWrappers) { - Log.LogMessage (MessageImportance.Low, "Generated {0} NativeAOT trimmable typemap ProGuard rules from {1} DGML file(s).", javaTypes.Count, NativeAotDgmlFiles.Length); - } else { - Log.LogMessage (MessageImportance.Low, "Generated {0} NativeAOT ProGuard rules keeping every Java type in the ACW map (Java trimming is disabled).", javaTypes.Count); - } - return !Log.HasLoggedErrors; - } - - List LoadJavaTypesFromAcwMap (HashSet? retainedTypeKeys) - { - var javaTypes = new List (retainedTypeKeys?.Count ?? 0); - var seenJavaTypes = new HashSet (StringComparer.Ordinal); - foreach (var line in File.ReadLines (AcwMapFile)) { - var separator = line.IndexOf (";", StringComparison.Ordinal); - if (separator <= 0 || separator == line.Length - 1) { - continue; - } - var managedTypeName = line.Substring (0, separator); - var javaTypeName = line.Substring (separator + 1); - if ((retainedTypeKeys == null || retainedTypeKeys.Contains (managedTypeName)) && seenJavaTypes.Add (javaTypeName)) { - javaTypes.Add (javaTypeName); - } - } - return javaTypes; - } - - HashSet LoadRetainedTypeKeysFromDgml () - { - var typeKeys = new HashSet (StringComparer.Ordinal); - foreach (var dgmlFile in NativeAotDgmlFiles) { - using var reader = XmlReader.Create (dgmlFile.ItemSpec, new XmlReaderSettings { - DtdProcessing = DtdProcessing.Prohibit, - XmlResolver = null, - }); - - bool readingNodes = false; - while (reader.Read ()) { - if (reader.NodeType == XmlNodeType.Element && reader.LocalName == "Nodes") { - readingNodes = true; - continue; - } - if (reader.NodeType == XmlNodeType.EndElement && reader.LocalName == "Nodes") { - break; - } - if (!readingNodes) { - continue; - } - if (reader.NodeType != XmlNodeType.Element || reader.LocalName != "Node") { - continue; - } - - var label = reader.GetAttribute ("Label"); - if (label.IsNullOrEmpty () || !label.StartsWith (TypeMetadataPrefix, StringComparison.Ordinal)) { - continue; - } - - var assemblyStart = TypeMetadataPrefix.Length; - var assemblyEnd = label.IndexOf (']', assemblyStart); - if (assemblyEnd < 0 || assemblyEnd == label.Length - 1) { - continue; - } - - var assemblyName = label.Substring (assemblyStart, assemblyEnd - assemblyStart); - var managedTypeName = label.Substring (assemblyEnd + 1); - typeKeys.Add ($"{managedTypeName}, {assemblyName}"); - } - } - - return typeKeys; - } -} diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs index ad89bebeb29..ee262d7d939 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs @@ -36,7 +36,7 @@ public class R8 : D8 public string? ProguardMappingFileOutput { get; set; } public string? BuildMetadataFileOutput { get; set; } public ITaskItem []? ProguardConfigurationFiles { get; set; } - public bool UseTrimmableNativeAotProguardConfiguration { get; set; } + public bool UseTypeMapProguardConfiguration { get; set; } public string ObfuscationMode { get; set; } = "private-members"; // User-authored AndroidJavaSource (Bind != true) .java files. These have no managed peer and are @@ -161,18 +161,21 @@ protected override string CreateResponseFile () } if (EnableShrinking) { - if (UseTrimmableNativeAotProguardConfiguration && !ProguardGeneratedApplicationConfiguration.IsNullOrEmpty ()) { - // ACW keep rules come from the DGML/acw-map-driven proguard_project_references.cfg on - // the trimmable path. User-authored AndroidJavaSource (Bind != true) has no managed peer + if (UseTypeMapProguardConfiguration) { + WriteArg (response, "--no-minification"); + } + if (UseTypeMapProguardConfiguration && !ProguardGeneratedApplicationConfiguration.IsNullOrEmpty ()) { + // Class roots come from retained typemap keys, not the complete ACW map. + // User-authored AndroidJavaSource (Bind != true) has no managed peer // and is absent from that map, so keep it here explicitly; otherwise R8 shrinks it away // (e.g. dropping large unreferenced sources so an app that needs multidex no longer does). using (var appcfg = File.CreateText (ProguardGeneratedApplicationConfiguration)) { - appcfg.WriteLine ("# ACW keep rules are generated from NativeAOT ILC metadata."); + appcfg.WriteLine ("# Class keep rules are generated from retained typemap keys."); foreach (var java in GetUserJavaTypes ()) { appcfg.WriteLine ($"-keep class {java} {{ *; }}"); } } - } else if (!AcwMapFile.IsNullOrEmpty ()) { + } else if (!UseTypeMapProguardConfiguration && !AcwMapFile.IsNullOrEmpty ()) { var acwMap = MonoAndroidHelper.LoadMapFile (BuildEngine4, Path.GetFullPath (AcwMapFile), StringComparer.OrdinalIgnoreCase); var javaTypes = new List (acwMap.Values.Count); foreach (var v in acwMap.Values) { @@ -192,11 +195,11 @@ protected override string CreateResponseFile () } if (!ProguardCommonXamarinConfiguration.IsNullOrWhiteSpace ()) { using (var xamcfg = File.CreateText (ProguardCommonXamarinConfiguration)) { - WriteObfuscationRules (xamcfg, ObfuscationMode); + WriteObfuscationRules (xamcfg, UseTypeMapProguardConfiguration ? "disabled" : ObfuscationMode); xamcfg.WriteLine (); xamcfg.Flush (); - if (UseTrimmableNativeAotProguardConfiguration) { - using var stream = GetEmbeddedResourceStream ("proguard_trimmable_nativeaot.cfg"); + if (UseTypeMapProguardConfiguration) { + using var stream = GetEmbeddedResourceStream ("proguard_typemap.cfg"); stream.CopyTo (xamcfg.BaseStream); } else { using var stream = GetEmbeddedResourceStream ("proguard_xamarin.cfg"); @@ -344,7 +347,7 @@ static string DescribeProguardSource (ITaskItem item) Stream GetEmbeddedResourceStream (string resourceName) { - var stream = GetType ().Assembly.GetManifestResourceStream (resourceName); + var stream = typeof (R8).Assembly.GetManifestResourceStream (resourceName); if (stream == null) { throw new InvalidOperationException ($"Missing embedded resource '{resourceName}'."); } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateNativeAotProguardConfigurationTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateNativeAotProguardConfigurationTests.cs deleted file mode 100644 index fd9679e8c27..00000000000 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateNativeAotProguardConfigurationTests.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System.IO; -using Microsoft.Build.Utilities; -using NUnit.Framework; -using Xamarin.Android.Tasks; - -namespace Xamarin.Android.Build.Tests; - -[TestFixture] -[Parallelizable (ParallelScope.Children)] -public class GenerateNativeAotProguardConfigurationTests : BaseTest -{ - [Test] - public void Execute_UsesDgmlTypeMetadata () - { - var path = Path.Combine (Root, "temp", TestName); - var dgmlFile = Path.Combine (path, "app.scan.dgml.xml"); - var acwMapFile = Path.Combine (path, "acw-map.txt"); - var outputFile = Path.Combine (path, "proguard", "proguard_project_references.cfg"); - Directory.CreateDirectory (path); - File.WriteAllText (dgmlFile, """ - - - - - - - - - - - - - - """); - File.WriteAllText (acwMapFile, """ - UnnamedProject.MainActivity, UnnamedProject;crc64a1.MainActivity - Android.App.Activity, Mono.Android;android.app.Activity - Duplicate.Type, My.Assembly;my.app.Duplicate - AndroidX.Activity.Result.Contract.ActivityResultContracts+TakePicture, Xamarin.AndroidX.Activity;androidx.activity.result.contract.ActivityResultContracts$TakePicture - Duplicate.Type;wrong.Duplicate - Other.Type;other.Type - """); - - var task = new GenerateNativeAotProguardConfiguration { - BuildEngine = new MockBuildEngine (TestContext.Out), - NativeAotDgmlFiles = new [] { new TaskItem (dgmlFile) }, - AcwMapFile = acwMapFile, - OutputFile = outputFile, - TrimJavaCallableWrappers = true, - }; - - Assert.IsTrue (task.Execute (), "Task should succeed."); - var proguard = File.ReadAllText (outputFile); - StringAssert.Contains ("-keep class crc64a1.MainActivity { *; }", proguard); - StringAssert.Contains ("-keep class android.app.Activity { *; }", proguard); - StringAssert.Contains ("-keep class my.app.Duplicate { *; }", proguard); - StringAssert.Contains ("-keep class androidx.activity.result.contract.ActivityResultContracts$TakePicture { *; }", proguard); - StringAssert.DoesNotContain ("wrong.Duplicate", proguard); - StringAssert.DoesNotContain ("other.Type", proguard); - } - - [Test] - public void Execute_KeepsAllWhenTrimmingDisabled () - { - var path = Path.Combine (Root, "temp", TestName); - var acwMapFile = Path.Combine (path, "acw-map.txt"); - var outputFile = Path.Combine (path, "proguard", "proguard_project_references.cfg"); - Directory.CreateDirectory (path); - File.WriteAllText (acwMapFile, """ - UnnamedProject.MainActivity, UnnamedProject;crc64a1.MainActivity - Android.App.Activity, Mono.Android;android.app.Activity - Duplicate.Type, My.Assembly;my.app.Duplicate - Other.Type;other.Type - """); - - var task = new GenerateNativeAotProguardConfiguration { - BuildEngine = new MockBuildEngine (TestContext.Out), - AcwMapFile = acwMapFile, - OutputFile = outputFile, - TrimJavaCallableWrappers = false, - }; - - Assert.IsTrue (task.Execute (), "Task should succeed without a DGML when trimming is disabled."); - var proguard = File.ReadAllText (outputFile); - StringAssert.Contains ("-keep class crc64a1.MainActivity { *; }", proguard); - StringAssert.Contains ("-keep class android.app.Activity { *; }", proguard); - StringAssert.Contains ("-keep class my.app.Duplicate { *; }", proguard); - StringAssert.Contains ("-keep class other.Type { *; }", proguard); - } - - [Test] - public void Execute_IgnoresDgmlWhenTrimmingDisabled () - { - var path = Path.Combine (Root, "temp", TestName); - var dgmlFile = Path.Combine (path, "app.scan.dgml.xml"); - var acwMapFile = Path.Combine (path, "acw-map.txt"); - var outputFile = Path.Combine (path, "proguard", "proguard_project_references.cfg"); - Directory.CreateDirectory (path); - File.WriteAllText (dgmlFile, """ - - - - - - - - """); - File.WriteAllText (acwMapFile, """ - UnnamedProject.MainActivity, UnnamedProject;crc64a1.MainActivity - Android.App.Activity, Mono.Android;android.app.Activity - Other.Type;other.Type - """); - - var task = new GenerateNativeAotProguardConfiguration { - BuildEngine = new MockBuildEngine (TestContext.Out), - NativeAotDgmlFiles = new [] { new TaskItem (dgmlFile) }, - AcwMapFile = acwMapFile, - OutputFile = outputFile, - TrimJavaCallableWrappers = false, - }; - - Assert.IsTrue (task.Execute (), "Task should succeed and ignore the DGML when trimming is disabled."); - var proguard = File.ReadAllText (outputFile); - StringAssert.Contains ("-keep class crc64a1.MainActivity { *; }", proguard); - StringAssert.Contains ("-keep class android.app.Activity { *; }", proguard); - StringAssert.Contains ("-keep class other.Type { *; }", proguard); - } -} diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/R8Tests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/R8Tests.cs index 1362ba24aed..9a634049435 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/R8Tests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/R8Tests.cs @@ -1,4 +1,5 @@ using System.IO; +using Microsoft.Build.Utilities; using NUnit.Framework; using Xamarin.Android.Tasks; @@ -77,5 +78,51 @@ public void WriteDisabledObfuscationRules () Assert.AreEqual ("-dontobfuscate" + System.Environment.NewLine, writer.ToString ()); } + + [Test] + public void RetainedTypeMapRulesDoNotRootAllAcwsOrObfuscatePrivateMembers () + { + var directory = Path.Combine (Path.GetTempPath (), "R8TypeMap_" + System.Guid.NewGuid ().ToString ("N")); + Directory.CreateDirectory (directory); + try { + var map = Path.Combine (directory, "acw-map.txt"); + File.WriteAllText (map, "Unused.Type;unused.Wrapper\n"); + var source = Path.Combine (directory, "UserSource.java"); + File.WriteAllText (source, "package example;\npublic class UserSource {}"); + var task = new R8ResponseTestTask { + BuildEngine = new MockBuildEngine (TestContext.Out), + UseTypeMapProguardConfiguration = true, + EnableShrinking = true, + ObfuscationMode = "private-members", + AcwMapFile = map, + JavaSourceFiles = [new TaskItem (source)], + JavaPlatformJarPath = Path.Combine (directory, "android.jar"), + ProguardGeneratedApplicationConfiguration = Path.Combine (directory, "primary.cfg"), + ProguardCommonXamarinConfiguration = Path.Combine (directory, "common.cfg"), + ResponseFile = Path.Combine (directory, "r8.rsp"), + }; + var response = task.WriteResponse (); + StringAssert.Contains ("--no-minification", response); + StringAssert.DoesNotContain ("--no-tree-shaking", response); + var primary = File.ReadAllText (task.ProguardGeneratedApplicationConfiguration); + StringAssert.DoesNotContain ("unused.Wrapper", primary); + StringAssert.Contains ("-keep class example.UserSource { *; }", primary); + var common = File.ReadAllText (task.ProguardCommonXamarinConfiguration); + StringAssert.Contains ("-dontobfuscate", common); + StringAssert.DoesNotContain ("-keep,allowshrinking,allowoptimization class **", common); + StringAssert.DoesNotContain ("-keep class mono.android.**", common); + } finally { + Directory.Delete (directory, recursive: true); + } + } + + sealed class R8ResponseTestTask : R8 + { + public string ResponseFile { get; set; } = ""; + + protected override string CreateResponseFilePath () => ResponseFile; + + public string WriteResponse () => File.ReadAllText (CreateResponseFile ()); + } } } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs index 38fd53867a1..91079cfd379 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs @@ -19,6 +19,52 @@ namespace Xamarin.Android.Build.Tests { [Category ("Node-2")] public class TrimmableTypeMapBuildTests : BaseTest { + [TestCase (AndroidRuntime.CoreCLR, "llvm-ir")] + [TestCase (AndroidRuntime.CoreCLR, "trimmable")] + [TestCase (AndroidRuntime.NativeAOT, "trimmable")] + public void RetainedTypeMapClassRulesDriveR8 (AndroidRuntime runtime, string implementation) + { + if (IgnoreUnsupportedConfiguration (runtime, release: true)) { + return; + } + var proj = new XamarinAndroidApplicationProject { IsRelease = true }; + proj.SetRuntime (runtime); + proj.SetProperty (KnownProperties.RuntimeIdentifier, "android-arm64"); + proj.SetProperty (KnownProperties.AndroidLinkTool, "r8"); + proj.SetProperty ("AndroidTypeMapImplementation", implementation); + proj.SetProperty ("TrimMode", "full"); + proj.SetProperty ("AndroidR8ObfuscationMode", "private-members"); + using var builder = CreateApkBuilder (); + Assert.IsTrue (builder.Build (proj)); + + var intermediate = builder.Output.GetIntermediaryPath ("android-arm64"); + var keysFile = Path.Combine (intermediate, "typemap.keys.txt"); + var keys = File.ReadAllLines (keysFile); + var live = keys.Single (key => key.EndsWith ("/MainActivity", StringComparison.Ordinal)); + const string dead = "mono/android/animation/Animator_AnimatorListenerImplementor"; + CollectionAssert.DoesNotContain (keys, dead); + var configuration = Path.Combine (intermediate, "proguard", "proguard_project_references.cfg"); + CollectionAssert.AreEqual ( + keys.Select (key => "-keep class " + key.Replace ('/', '.')).OrderBy (line => line, StringComparer.Ordinal), + File.ReadAllLines (configuration)); + var common = File.ReadAllText (Path.Combine (intermediate, "proguard", "proguard_xamarin.cfg")); + StringAssert.Contains ("-dontobfuscate", common); + StringAssert.Contains ("-keepclassmembers class *", common); + StringAssert.DoesNotContain ("-keep class mono.android.**", common); + StringAssert.DoesNotContain ("-keep class net.dot.jni.**", common); + var dex = Path.Combine (intermediate, "android", "bin", "classes.dex"); + Assert.IsTrue (DexUtils.ContainsClass ($"L{live};", dex, AndroidSdkPath), "Live class names must not be obfuscated."); + Assert.IsFalse (DexUtils.ContainsClass ($"L{dead};", dex, AndroidSdkPath), "Dead wrappers must not be rooted by runtime package rules."); + + Assert.IsTrue (builder.Build (proj)); + builder.Output.AssertTargetIsSkipped ("_AndroidExtractTypeMapKeys"); + builder.Output.AssertTargetIsSkipped ("_AndroidGenerateTypeMapProguardConfiguration"); + File.Delete (configuration); + Assert.IsTrue (builder.Build (proj)); + builder.Output.AssertTargetIsNotSkipped ("_AndroidGenerateTypeMapProguardConfiguration"); + FileAssert.Exists (configuration); + } + [Test] public void Build_WithTrimmableTypeMap_Succeeds ([Values] bool isRelease, [Values (AndroidRuntime.CoreCLR, AndroidRuntime.NativeAOT)] AndroidRuntime runtime) { @@ -1513,7 +1559,8 @@ public void Build_WithTrimmableTypeMap_IncrementalBuild ([Values] bool isRelease builder.Output.IsTargetSkipped ("_GenerateJavaStubs"), "_GenerateJavaStubs should be skipped on incremental build."); if (trimNativeAotJavaCode) { - builder.Output.AssertTargetIsSkipped ("_GenerateTrimmableTypeMapProguardConfiguration"); + builder.Output.AssertTargetIsSkipped ("_AndroidExtractTypeMapKeys"); + builder.Output.AssertTargetIsSkipped ("_AndroidGenerateTypeMapProguardConfiguration"); Assert.AreEqual (scanDgmlTimestamp, File.GetLastWriteTimeUtc (scanDgml), "No-op builds should not rewrite the scan DGML."); } if (isRelease && runtime == AndroidRuntime.CoreCLR) { @@ -2914,10 +2961,22 @@ void AssertPostTrimR8InputsExcludeDeadFrameworkImplementor (string dexFile, stri Assert.IsFalse (acwMap.Contains (deadManagedType, StringComparison.Ordinal), $"{acwMapPath} should be based on linked assemblies."); Assert.IsFalse (acwMap.Contains (deadJavaDotName, StringComparison.Ordinal), $"{acwMapPath} should not keep removed framework listener implementors."); - FileAssert.Exists (proguardPrimaryPath, "R8 should generate a primary proguard configuration from the post-trim acw-map."); + FileAssert.Exists (proguardPrimaryPath, "R8 should generate a separate configuration for user Java sources."); Assert.IsFalse ( File.ReadAllText (proguardPrimaryPath).Contains (deadJavaDotName, StringComparison.Ordinal), $"{proguardPrimaryPath} should not keep removed framework listener implementors."); + var proguardDirectory = Path.GetDirectoryName (proguardPrimaryPath); + Assert.IsNotNull (proguardDirectory); + var references = Path.Combine (proguardDirectory, "proguard_project_references.cfg"); + var rules = File.ReadAllLines (references); + Assert.IsNotEmpty (rules, "Retained typemap keys should provide class roots."); + Assert.IsTrue (rules.All (line => line.StartsWith ("-keep class ", StringComparison.Ordinal) && !line.Contains ('{')), + "Typemap ProGuard rules must contain only class roots, not member rules."); + Assert.IsFalse (rules.Any (line => line.Contains (deadJavaDotName, StringComparison.Ordinal)), + "Retained typemap class roots should exclude trimmed framework implementors."); + var common = File.ReadAllText (Path.Combine (proguardDirectory, "proguard_xamarin.cfg")); + StringAssert.Contains ("-dontobfuscate", common); + StringAssert.DoesNotContain ("-keep,allowshrinking,allowoptimization class **", common); FileAssert.Exists (dexFile, "R8 should produce classes.dex."); Assert.IsFalse ( diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets index a70c32a55d5..e8a75d5a8a3 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets @@ -1002,6 +1002,12 @@ because xbuild doesn't support framework reference assemblies. <_PropertyCacheItems Include="ProjectFullPath=$(MSBuildProjectFullPath)" /> <_PropertyCacheItems Include="AndroidUseDesignerAssembly=$(AndroidUseDesignerAssembly)" /> <_PropertyCacheItems Include="AndroidTypeMapImplementation=$(AndroidTypeMapImplementation)" /> + <_PropertyCacheItems Include="_AndroidRuntime=$(_AndroidRuntime)" /> + <_PropertyCacheItems Include="PublishTrimmed=$(PublishTrimmed)" /> + <_PropertyCacheItems Include="Optimize=$(Optimize)" /> + <_PropertyCacheItems Include="_AndroidTrimmableTypemapTrimJavaCode=$(_AndroidTrimmableTypemapTrimJavaCode)" /> + <_PropertyCacheItems Include="_UseTrimmableNativeAotProguardConfiguration=$(_UseTrimmableNativeAotProguardConfiguration)" /> + <_PropertyCacheItems Include="ProguardConfigFiles=$(ProguardConfigFiles)" /> <_PropertyCacheItems Include="_AndroidUseMarshalMethods=$(_AndroidUseMarshalMethods)" /> <_PropertyCacheItems Include="_AndroidJcwCodegenTarget=$(_AndroidJcwCodegenTarget)" /> <_PropertyCacheItems Include="_AndroidAssemblyStoreCompressionLevel=$(_AndroidAssemblyStoreCompressionLevel)" /> @@ -2026,8 +2032,8 @@ because xbuild doesn't support framework reference assemblies. <_ProguardConfiguration Include="$(ProguardConfigFiles)" /> - <_ProguardConfiguration Include="$(MSBuildThisFileDirectory)proguard-android.txt" Condition=" '$(AndroidR8ObfuscationMode)' == 'disabled' " /> - <_ProguardConfiguration Include="$(MSBuildThisFileDirectory)proguard-android-optimize.txt" Condition=" '$(AndroidR8ObfuscationMode)' != 'disabled' " /> + <_ProguardConfiguration Include="$(MSBuildThisFileDirectory)proguard-android.txt" Condition=" '$(AndroidR8ObfuscationMode)' == 'disabled' or '$(_AndroidUseTypeMapProguardConfiguration)' == 'true' " /> + <_ProguardConfiguration Include="$(MSBuildThisFileDirectory)proguard-android-optimize.txt" Condition=" '$(AndroidR8ObfuscationMode)' != 'disabled' and '$(_AndroidUseTypeMapProguardConfiguration)' != 'true' " /> <_ProguardConfiguration Include="$(IntermediateOutputPath)proguard\proguard_xamarin.cfg" Condition=" '$(AndroidLinkTool)' != '' " /> <_ProguardConfiguration Include="$(_ProguardProjectConfiguration)" Condition=" '$(AndroidLinkTool)' != '' " /> <_ProguardConfiguration Include="$(IntermediateOutputPath)proguard\proguard_project_primary.cfg" Condition=" '$(AndroidLinkTool)' != '' " /> @@ -3110,5 +3116,6 @@ because xbuild doesn't support framework reference assemblies. Condition=" '$(AndroidTypeMapImplementation)' == 'trimmable' " /> + diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.D8.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.D8.targets index 4c4efd1f8b7..8f698a2033c 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.D8.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.D8.targets @@ -19,7 +19,7 @@ Copyright (C) 2018 Xamarin. All rights reserved. @@ -80,7 +80,7 @@ Copyright (C) 2018 Xamarin. All rights reserved. ProguardMappingFileOutput="$(AndroidProguardMappingFile)" BuildMetadataFileOutput="$(_AndroidR8BuildMetadataFile)" ProguardConfigurationFiles="@(_ProguardConfiguration)" - UseTrimmableNativeAotProguardConfiguration="$(_UseTrimmableNativeAotProguardConfiguration)" + UseTypeMapProguardConfiguration="$(_AndroidUseTypeMapProguardConfiguration)" ObfuscationMode="$(AndroidR8ObfuscationMode)" EnableShrinking="$(_R8EnableShrinking)" EnableMultiDex="$(AndroidEnableMultiDex)" diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs new file mode 100644 index 00000000000..c70bb64ad07 --- /dev/null +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs @@ -0,0 +1,332 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Xml.Linq; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Microsoft.Android.Tasks; +using Xunit; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap.Tests; + +public class TypeMapProguardTests : IDisposable +{ + readonly string directory = Path.Combine (Path.GetTempPath (), "typemap-proguard-" + Guid.NewGuid ().ToString ("N")); + readonly TypeMapTaskBuildEngine engine = new (); + + public TypeMapProguardTests () => Directory.CreateDirectory (directory); + + [Fact] + public void GeneratorUnionsCanonicalKeysAndWritesOnlyClassRules () + { + var first = Write ("first.keys", "test/Outer$Inner\r\nandroid/app/Activity\r\n\r\ntest/Caf\u00e9\n"); + var second = Write ("second.keys", "android/app/Activity\ntest/\U00010428Peer\n"); + var task = CreateGenerator (first, second); + Assert.True (task.Execute ()); + var expected = "-keep class android.app.Activity\n-keep class test.Caf\u00e9\n-keep class test.Outer$Inner\n-keep class test.\U00010428Peer\n"; + Assert.Equal (new UTF8Encoding (false).GetBytes (expected), File.ReadAllBytes (task.OutputFile)); + } + + [Theory] + [InlineData ("test/*")] + [InlineData ("test/Foo { *; }")] + [InlineData ("test/Foo[1]")] + [InlineData ("test.Foo")] + [InlineData ("test//Foo")] + [InlineData ("/test/Foo")] + [InlineData ("test/Foo/")] + [InlineData ("test/Foo;")] + [InlineData ("test/Foo\n-keep class **")] + [InlineData ("\ufefftest/Foo")] + public void GeneratorRejectsRuleInjectionAndNoncanonicalKeys (string input) + { + var task = CreateGenerator (Write ("invalid.keys", input)); + Assert.False (task.Execute ()); + Assert.Contains (engine.Errors, error => error.Code == "XA4328"); + Assert.False (File.Exists (task.OutputFile)); + } + + [Fact] + public void EmptyMapOverwritesAndRefreshesRealOutput () + { + var task = CreateGenerator (Write ("empty.keys", "")); + Assert.True (task.Execute ()); + var old = DateTime.UtcNow.AddDays (-1); + File.SetLastWriteTimeUtc (task.OutputFile, old); + Assert.True (task.Execute ()); + Assert.Empty (File.ReadAllBytes (task.OutputFile)); + Assert.True (File.GetLastWriteTimeUtc (task.OutputFile) > old); + } + + [Fact] + public void MissingMapIsAnError () + { + var task = CreateGenerator (Path.Combine (directory, "missing.keys")); + Assert.False (task.Execute ()); + Assert.Contains (engine.Errors, error => error.Code == "XA4328"); + } + + [Fact] + public void LlvmTargetTracksAbiUnionInputListAndDeletedOutputs () + { + Write ("first.ll", "@java_type_names = dso_local local_unnamed_addr constant [10 x i8] c\"test/Live\\00\", align 1\n"); + Write ("second.ll", "@java_type_names = dso_local local_unnamed_addr constant [12 x i8] c\"test/Second\\00\", align 1\n"); + var project = CreateProject ("CoreCLR", "llvm-ir", + new XElement ("_TypeMapAssemblySource", new XAttribute ("Include", "$(MSBuildProjectDirectory)/first.ll")), + new XElement ("_TypeMapAssemblySource", new XAttribute ("Include", "$(MSBuildProjectDirectory)/second.ll"), + new XAttribute ("Condition", "'$(OneAbi)' != 'true'"))); + Build (project); + var keys = Path.Combine (directory, "obj", "typemap.keys.txt"); + var rules = Path.Combine (directory, "obj", "proguard", "proguard_project_references.cfg"); + Assert.Equal ("-keep class test.Live\n-keep class test.Second\n", File.ReadAllText (rules)); + var firstTime = File.GetLastWriteTimeUtc (keys); + var rulesTime = File.GetLastWriteTimeUtc (rules); + Build (project); + Assert.Equal (firstTime, File.GetLastWriteTimeUtc (keys)); + Assert.Equal (rulesTime, File.GetLastWriteTimeUtc (rules)); + + Build (project, "-p:OneAbi=true"); + Assert.Equal ("test/Live\n", File.ReadAllText (keys)); + Assert.Equal ("-keep class test.Live\n", File.ReadAllText (rules)); + File.Delete (keys); + Build (project, "-p:OneAbi=true"); + Assert.Equal ("test/Live\n", File.ReadAllText (keys)); + File.Delete (rules); + Build (project, "-p:OneAbi=true"); + Assert.Equal ("-keep class test.Live\n", File.ReadAllText (rules)); + Write ("first.ll", "@java_type_names = dso_local local_unnamed_addr constant [13 x i8] c\"test/Changed\\00\", align 1\n"); + Build (project, "-p:OneAbi=true"); + Assert.Equal ("-keep class test.Changed\n", File.ReadAllText (rules)); + + var writes = File.ReadAllText (Path.Combine (directory, "writes.txt")); + Assert.Contains ("typemap.keys.txt", writes); + Assert.Contains ("typemap.keys.inputs", writes); + Assert.Contains ("proguard_project_references.cfg", writes); + } + + [Fact] + public void DgmlTargetUnionsRequestedGraphsAndHonorsDisabledTrimming () + { + Write ("first.dgml", """"""); + Write ("second.dgml", """"""); + Write ("acw-map.txt", "App.Live, App;test.Live\nApp.Second, App;test.Second\nApp.Dead, App;test.Dead\n"); + var project = CreateProject ("NativeAOT", "trimmable", + new XElement ("ResolvedFileToPublish", new XAttribute ("Include", "first.so"), + new XAttribute ("AndroidTypeMapDgmlFile", "$(MSBuildProjectDirectory)/first.dgml")), + new XElement ("ResolvedFileToPublish", new XAttribute ("Include", "second.so"), + new XAttribute ("AndroidTypeMapDgmlFile", "$(MSBuildProjectDirectory)/second.dgml"))); + Build (project); + var rules = Path.Combine (directory, "obj", "proguard", "proguard_project_references.cfg"); + Assert.Equal ("-keep class test.Live\n-keep class test.Second\n", File.ReadAllText (rules)); + Build (project, "-p:_AndroidTrimmableTypemapTrimJavaCode=false"); + Assert.Equal ("-keep class test.Dead\n-keep class test.Live\n-keep class test.Second\n", File.ReadAllText (rules)); + File.Delete (Path.Combine (directory, "second.dgml")); + var output = Build (project, expectSuccess: false); + Assert.Contains ("XA4321", output); + } + + [Fact] + public void RuntimeSwitchCannotReuseAnotherRepresentation () + { + Write ("first.ll", "@java_type_names = dso_local local_unnamed_addr constant [10 x i8] c\"test/Llvm\\00\", align 1\n"); + Write ("app.dgml", """"""); + Write ("acw-map.txt", "App.Live, App;test.Native\n"); + var project = CreateProject ("CoreCLR", "llvm-ir", + new XElement ("_TypeMapAssemblySource", new XAttribute ("Include", "$(MSBuildProjectDirectory)/first.ll")), + new XElement ("ResolvedFileToPublish", new XAttribute ("Include", "app.so"), + new XAttribute ("AndroidTypeMapDgmlFile", "$(MSBuildProjectDirectory)/app.dgml"))); + var rules = Path.Combine (directory, "obj", "proguard", "proguard_project_references.cfg"); + Build (project); + Assert.Equal ("-keep class test.Llvm\n", File.ReadAllText (rules)); + Build (project, "-p:_AndroidRuntime=NativeAOT", "-p:AndroidTypeMapImplementation=trimmable"); + Assert.Equal ("-keep class test.Native\n", File.ReadAllText (rules)); + Build (project); + Assert.Equal ("-keep class test.Llvm\n", File.ReadAllText (rules)); + } + + [Fact] + public void CompleteConfigurationOverridePreservesLegacyR8Policy () + { + Write ("first.ll", "@java_type_names = dso_local local_unnamed_addr constant [10 x i8] c\"test/Live\\00\", align 1\n"); + var project = CreateProject ("CoreCLR", "llvm-ir", + new XElement ("_TypeMapAssemblySource", new XAttribute ("Include", "$(MSBuildProjectDirectory)/first.ll"))); + Build (project); + Assert.Contains ("UseTypeMap=true", File.ReadAllText (Path.Combine (directory, "writes.txt"))); + Build (project, "-p:ProguardConfigFiles=custom.cfg"); + Assert.DoesNotContain ("UseTypeMap=true", File.ReadAllText (Path.Combine (directory, "writes.txt"))); + } + + [Fact] + public void InnerBuildDoesNotGenerateOuterClassRules () + { + var project = CreateProject ("CoreCLR", "trimmable"); + Build (project, "-p:_ComputeFilesToPublishForRuntimeIdentifiers=true"); + Assert.False (File.Exists (Path.Combine (directory, "obj", "typemap.keys.txt"))); + } + + [Fact] + public void AssemblyTargetConsumesLinkedMetadataAcrossRidsAndEmptyStubs () + { + string EmitMap (string name, params string [] keys) + { + var path = Path.Combine (directory, name + ".dll"); + var model = new TypeMapAssemblyData { AssemblyName = name, ModuleName = name + ".dll" }; + foreach (var key in keys) { + model.Entries.Add (new TypeMapAttributeData { + MapKey = key, ProxyTypeReference = "System.Object, System.Runtime", + }); + } + using var stream = File.Create (path); + new TypeMapAssemblyEmitter (new Version (11, 0, 0, 0)).Emit (model, stream); + return path; + } + var first = EmitMap ("first", "test/Live", "test/Alias[0]"); + var second = EmitMap ("second", "test/Second", "test/Alias[1]"); + var stub = EmitMap ("stub"); + var project = CreateProject ("CoreCLR", "trimmable", + new XElement ("ResolvedFileToPublish", new XAttribute ("Include", "arm64/R2R/root.dll"), + new XAttribute ("AndroidTypeMapLinkedAssemblies", first + ";" + stub)), + new XElement ("ResolvedFileToPublish", new XAttribute ("Include", "x64/R2R/root.dll"), + new XAttribute ("AndroidTypeMapLinkedAssemblies", second)), + new XElement ("ResolvedFileToPublish", new XAttribute ("Include", "pretrim/unused.dll"))); + Build (project); + var rules = Path.Combine (directory, "obj", "proguard", "proguard_project_references.cfg"); + Assert.Equal ("-keep class test.Alias\n-keep class test.Live\n-keep class test.Second\n", File.ReadAllText (rules)); + File.Delete (second); + Assert.Contains ("XA4327", Build (project, expectSuccess: false)); + } + + [Theory] + [InlineData ("NativeAOT", "true", "custom-native/App.scan.dgml.xml")] + [InlineData ("NativeAOT", "false", "custom-native/App.codegen.dgml.xml")] + [InlineData ("CoreCLR", "true", "obj/linked/_Binding.TypeMap.dll")] + public void InnerBuildReturnsExactProducerPaths (string runtime, string optimize, string expected) + { + var project = CreateProject (runtime, "trimmable"); + var document = XDocument.Load (project); + var root = document.Root ?? throw new InvalidOperationException (); + root.AddFirst (new XElement ("Import", new XAttribute ("Project", + Path.Combine (RepositoryDirectory (), "src", "Xamarin.Android.Build.Tasks", "Microsoft.Android.Sdk", "targets", "Microsoft.Android.Sdk.AssemblyResolution.targets")))); + root.Add (new XElement ("PropertyGroup", + new XElement ("RuntimeIdentifier", "android-x64"), + new XElement ("NativeIntermediateOutputPath", "$(MSBuildProjectDirectory)/custom-native/"), + new XElement ("TargetName", "App"), + new XElement ("Optimize", optimize), + new XElement ("_TypeMapAssemblyName", "_Microsoft.Android.TypeMaps"))); + foreach (var target in new [] { "BuildOnlySettings", "_CheckForInvalidConfigurationAndPlatform", "_FixupIntermediateAssembly", "_PatchNuGetReferenceMetadata", "ResolveReferences", "_AndroidAot" }) { + root.Add (new XElement ("Target", new XAttribute ("Name", target))); + } + root.Add (new XElement ("Target", new XAttribute ("Name", "ComputeFilesToPublish"), + new XElement ("ItemGroup", + new XElement ("_GeneratedTypeMapAssembliesFromList", new XAttribute ("Include", "generated/_Binding.TypeMap.dll")), + new XElement ("ResolvedFileToPublish", new XAttribute ("Include", "R2R/_Microsoft.Android.TypeMaps.dll"))))); + var metadata = runtime == "NativeAOT" ? "AndroidTypeMapDgmlFile" : "AndroidTypeMapLinkedAssemblies"; + root.Add (new XElement ("Target", new XAttribute ("Name", "Build"), + new XAttribute ("DependsOnTargets", "_ComputeFilesToPublishForRuntimeIdentifiers"), + new XElement ("WriteLinesToFile", new XAttribute ("File", "$(MSBuildProjectDirectory)/producer.txt"), + new XAttribute ("Lines", $"@(ResolvedFileToPublish->'%({metadata})')"), + new XAttribute ("Overwrite", "true")))); + document.Save (project); + Build (project); + Assert.Equal (Path.Combine (directory, expected).Replace ('\\', '/'), + File.ReadAllText (Path.Combine (directory, "producer.txt")).Trim ().Replace ('\\', '/')); + } + + [Theory] + [InlineData ("MonoVM", "llvm-ir", "true", "r8")] + [InlineData ("CoreCLR", "llvm-ir", "false", "r8")] + [InlineData ("CoreCLR", "llvm-ir", "true", "")] + public void InactivePathsDoNotConsumeOrGenerateKeys (string runtime, string representation, string trimmed, string linkTool) + { + var project = CreateProject (runtime, representation); + Build (project, $"-p:PublishTrimmed={trimmed}", $"-p:AndroidLinkTool={linkTool}"); + Assert.False (File.Exists (Path.Combine (directory, "obj", "typemap.keys.txt"))); + } + + GenerateTypeMapProguardConfiguration CreateGenerator (params string [] inputs) + { + var items = new ITaskItem [inputs.Length]; + for (int i = 0; i < inputs.Length; i++) { + items [i] = new TaskItem (inputs [i]); + } + return new GenerateTypeMapProguardConfiguration { + BuildEngine = engine, + TypeMapKeyFiles = items, + OutputFile = Path.Combine (directory, "rules", "classes.cfg"), + }; + } + + string CreateProject (string runtime, string representation, params XElement [] sourceItems) + { + var targets = Path.Combine (RepositoryDirectory (), "src", "Xamarin.Android.Build.Tasks", "Microsoft.Android.Sdk", "targets", "Microsoft.Android.Sdk.TypeMap.Proguard.targets"); + var path = Path.Combine (directory, "pipeline.proj"); + Directory.CreateDirectory (Path.Combine (directory, "obj")); + new XDocument ( + new XElement ("Project", + new XElement ("PropertyGroup", + new XElement ("_MicrosoftAndroidBuildTasksAssembly", typeof (GenerateTypeMapProguardConfiguration).Assembly.Location), + new XElement ("_XamarinAndroidBuildTasksAssembly", "unused-legacy-tasks.dll"), + new XElement ("_AndroidRuntime", runtime), + new XElement ("AndroidTypeMapImplementation", representation), + new XElement ("PublishTrimmed", "true"), + new XElement ("AndroidLinkTool", "r8"), + new XElement ("_AndroidTrimmableTypemapTrimJavaCode", "true"), + new XElement ("IntermediateOutputPath", "$(MSBuildProjectDirectory)/obj/"), + new XElement ("_AcwMapFile", "$(MSBuildProjectDirectory)/acw-map.txt")), + new XElement ("Import", new XAttribute ("Project", targets)), + new XElement ("Target", new XAttribute ("Name", "_GenerateJavaStubs"), + new XElement ("ItemGroup", sourceItems)), + new XElement ("Target", new XAttribute ("Name", "_CalculateProguardConfigurationFiles")), + new XElement ("Target", new XAttribute ("Name", "Build"), + new XAttribute ("DependsOnTargets", "_CalculateProguardConfigurationFiles;_AndroidGenerateTypeMapProguardConfiguration"), + new XElement ("WriteLinesToFile", new XAttribute ("File", "$(MSBuildProjectDirectory)/writes.txt"), + new XAttribute ("Lines", "@(FileWrites);UseTypeMap=$(_AndroidUseTypeMapProguardConfiguration)"), new XAttribute ("Overwrite", "true"))))) + .Save (path); + return path; + } + + static string RepositoryDirectory () + { + var repository = new DirectoryInfo (AppContext.BaseDirectory); + while (repository != null && !File.Exists (Path.Combine (repository.FullName, "Configuration.props"))) { + repository = repository.Parent; + } + Assert.NotNull (repository); + return repository.FullName; + } + + string Build (string project, params string [] arguments) => Build (project, true, arguments); + + string Build (string project, bool expectSuccess, params string [] arguments) + { + var start = new ProcessStartInfo ("dotnet") { + WorkingDirectory = directory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + foreach (var argument in new [] { "msbuild", project, "-t:Build", "-nologo", "-v:minimal", "-nr:false" }) { + start.ArgumentList.Add (argument); + } + foreach (var argument in arguments) { + start.ArgumentList.Add (argument); + } + using var process = Process.Start (start) ?? throw new InvalidOperationException ("Could not start MSBuild."); + var stdout = process.StandardOutput.ReadToEndAsync (); + var stderr = process.StandardError.ReadToEndAsync (); + Assert.True (process.WaitForExit (120000), "MSBuild did not finish."); + var output = stdout.GetAwaiter ().GetResult () + stderr.GetAwaiter ().GetResult (); + Assert.True (expectSuccess ? process.ExitCode == 0 : process.ExitCode != 0, output); + return output; + } + + string Write (string name, string content) + { + var path = Path.Combine (directory, name); + File.WriteAllText (path, content, new UTF8Encoding (false)); + return path; + } + + public void Dispose () => Directory.Delete (directory, recursive: true); +} From 5236ed04ada4e8a916f6b25516ea443f07b2c357 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 18 Sep 2026 09:20:38 +0200 Subject: [PATCH 16/25] Make typemap-driven R8 trimming privately configurable Honor _AndroidEnableTypemapR8Trimming without a default: false selects legacy retention and avoids automatic DGML, while true/unset retain supported pipeline eligibility. Invalidate stale generated rules across switch transitions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Documentation/guides/D8andR8.md | 8 ++ ...oft.Android.Sdk.AssemblyResolution.targets | 8 +- ...crosoft.Android.Sdk.TypeMap.LlvmIr.targets | 2 +- ...osoft.Android.Sdk.TypeMap.Proguard.targets | 33 +++++++- ...roid.Sdk.TypeMap.Trimmable.CoreCLR.targets | 2 +- ...id.Sdk.TypeMap.Trimmable.NativeAOT.targets | 6 +- .../Xamarin.Android.Common.targets | 1 + .../Generator/TypeMapProguardTests.cs | 82 ++++++++++++++++++- 8 files changed, 127 insertions(+), 15 deletions(-) diff --git a/Documentation/guides/D8andR8.md b/Documentation/guides/D8andR8.md index c3b9df206ee..4ac8ea94245 100644 --- a/Documentation/guides/D8andR8.md +++ b/Documentation/guides/D8andR8.md @@ -50,6 +50,14 @@ classes to disappear. It keeps explicit runtime bootstrap roots rather than whole wrapper packages. User Java source retention and application/library ProGuard rules remain separate. +The temporary private override `_AndroidEnableTypemapR8Trimming` controls this +pipeline. Leave it unset for the automatic behavior, set it to `true` to enable +the pipeline in eligible managed-trimmed CoreCLR/NativeAOT R8 builds, or set it +to `false` to use legacy ACW retention without running the new tasks. Disabling +it also avoids automatic NativeAOT DGML generation, but does not suppress +explicit `IlcGenerateDgmlFile` diagnostics. It does not enable R8 or managed +trimming in otherwise ineligible builds. + This pipeline disables all obfuscation, including private-member obfuscation, with both `-dontobfuscate` and R8's `--no-minification` option. MonoVM, nonshrinking/multidex-only builds, and the existing complete 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 60c721e2b76..a906cdb0af2 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 @@ -77,13 +77,13 @@ _ResolveAssemblies MSBuild target. <_AndroidLinkedTypeMapKeySource Include="@(_GeneratedTypeMapAssembliesFromList->'$(IntermediateOutputPath)linked\%(Filename)%(Extension)')" - Condition=" '$(_AndroidRuntime)' == 'CoreCLR' and '$(AndroidTypeMapImplementation)' == 'trimmable' and '$(PublishTrimmed)' == 'true' " /> + Condition=" '$(_AndroidEnableTypemapR8Trimming)' != 'false' and '$(_AndroidRuntime)' == 'CoreCLR' and '$(AndroidTypeMapImplementation)' == 'trimmable' and '$(PublishTrimmed)' == 'true' " /> - + + + + + + + + + @@ -45,7 +70,7 @@ <_AndroidTypeMapKeySource Include="$(_AndroidTypeMapLinkedAssemblies)" Condition=" '$(_AndroidTypeMapKeysKind)' == 'assemblies' " /> <_AndroidTypeMapKeysState Remove="@(_AndroidTypeMapKeysState)" /> - <_AndroidTypeMapKeysState Include="Runtime=$(_AndroidRuntime);Representation=$(_AndroidTypeMapKeysKind);TrimJava=$(_AndroidTrimmableTypemapTrimJavaCode);Optimize=$(Optimize);RuntimeIdentifier=$(RuntimeIdentifier);RuntimeIdentifiers=$(RuntimeIdentifiers);TypemapPolicy=$(_AndroidUseTypeMapProguardConfiguration)" /> + <_AndroidTypeMapKeysState Include="Runtime=$(_AndroidRuntime);Representation=$(_AndroidTypeMapKeysKind);TrimJava=$(_AndroidTrimmableTypemapTrimJavaCode);Optimize=$(Optimize);RuntimeIdentifier=$(RuntimeIdentifier);RuntimeIdentifiers=$(RuntimeIdentifiers);TypemapPolicy=$(_AndroidUseTypeMapProguardConfiguration);Enabled=$(_AndroidEnableTypemapR8Trimming)" /> <_AndroidTypeMapKeysState Include="@(_AndroidTypeMapKeySource->'%(FullPath)')" /> <_AndroidBuildRuntimeIdentifiersInParallel - Condition=" ('$(_AndroidTrimmableTypemapTrimJavaCode)' == 'true' or '$(IlcGenerateDgmlFile)' == 'true') and '$(_AndroidBuildRuntimeIdentifiersInParallel)' == '' ">false + Condition=" (('$(_AndroidEnableTypemapR8Trimming)' != 'false' and '$(_AndroidTrimmableTypemapTrimJavaCode)' == 'true') or '$(IlcGenerateDgmlFile)' == 'true') and '$(_AndroidBuildRuntimeIdentifiersInParallel)' == '' ">false <_TrimmableRuntimeProviderJavaName Condition=" '$(_TrimmableRuntimeProviderJavaName)' == '' ">net.dot.jni.nativeaot.NativeAotRuntimeProvider r8 d8 @@ -60,10 +60,10 @@ scanner does not run. Avoid writing the other several-hundred-megabyte graph unless the user explicitly requested the full ILC diagnostics. --> diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets index e8a75d5a8a3..5827cd43038 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets @@ -1006,6 +1006,7 @@ because xbuild doesn't support framework reference assemblies. <_PropertyCacheItems Include="PublishTrimmed=$(PublishTrimmed)" /> <_PropertyCacheItems Include="Optimize=$(Optimize)" /> <_PropertyCacheItems Include="_AndroidTrimmableTypemapTrimJavaCode=$(_AndroidTrimmableTypemapTrimJavaCode)" /> + <_PropertyCacheItems Include="_AndroidEnableTypemapR8Trimming=$(_AndroidEnableTypemapR8Trimming)" /> <_PropertyCacheItems Include="_UseTrimmableNativeAotProguardConfiguration=$(_UseTrimmableNativeAotProguardConfiguration)" /> <_PropertyCacheItems Include="ProguardConfigFiles=$(ProguardConfigFiles)" /> <_PropertyCacheItems Include="_AndroidUseMarshalMethods=$(_AndroidUseMarshalMethods)" /> diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs index c70bb64ad07..d42cef54551 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs @@ -157,6 +157,79 @@ public void CompleteConfigurationOverridePreservesLegacyR8Policy () Assert.DoesNotContain ("UseTypeMap=true", File.ReadAllText (Path.Combine (directory, "writes.txt"))); } + [Theory] + [InlineData ("CoreCLR", "llvm-ir")] + [InlineData ("CoreCLR", "trimmable")] + [InlineData ("NativeAOT", "trimmable")] + public void DisabledPipelineNeedsNoTypemapInputsOrModernTaskAssembly (string runtime, string representation) + { + Write ("acw-map.txt", "App.Live, App;test.Live\n"); + var project = CreateProject (runtime, representation); + Build (project, "-p:_AndroidEnableTypemapR8Trimming=false", "-p:_MicrosoftAndroidBuildTasksAssembly=missing.dll"); + Assert.False (File.Exists (Path.Combine (directory, "obj", "typemap.keys.txt"))); + Assert.DoesNotContain ("UseTypeMap=true", File.ReadAllText (Path.Combine (directory, "writes.txt"))); + if (runtime == "NativeAOT") { + Assert.Equal ("# Class roots are supplied by the legacy ACW configuration.", + File.ReadAllText (Path.Combine (directory, "obj", "proguard", "proguard_project_references.cfg")).Trim ()); + } + } + + [Theory] + [InlineData ("CoreCLR", "llvm-ir")] + [InlineData ("NativeAOT", "trimmable")] + public void EnablingDisablingAndUnsettingCannotReuseLegacyRules (string runtime, string representation) + { + Write ("first.ll", "@java_type_names = dso_local local_unnamed_addr constant [10 x i8] c\"test/Live\\00\", align 1\n"); + Write ("app.dgml", """"""); + Write ("acw-map.txt", "App.Live, App;test.Live\n"); + var project = CreateProject (runtime, representation, + new XElement ("_TypeMapAssemblySource", new XAttribute ("Include", "$(MSBuildProjectDirectory)/first.ll")), + new XElement ("ResolvedFileToPublish", new XAttribute ("Include", "app.so"), + new XAttribute ("AndroidTypeMapDgmlFile", "$(MSBuildProjectDirectory)/app.dgml"))); + var keys = Path.Combine (directory, "obj", "typemap.keys.txt"); + var rules = Path.Combine (directory, "obj", "proguard", "proguard_project_references.cfg"); + foreach (var enabled in new [] { "true", "" }) { + Build (project, "-p:_AndroidEnableTypemapR8Trimming=true"); + var keysTime = File.GetLastWriteTimeUtc (keys); + Build (project, "-p:_AndroidEnableTypemapR8Trimming=false"); + Assert.Equal (keysTime, File.GetLastWriteTimeUtc (keys)); + Assert.DoesNotContain ("UseTypeMap=true", File.ReadAllText (Path.Combine (directory, "writes.txt"))); + // The CoreCLR legacy producer runs in the RID inner build, outside this fixture. + if (runtime == "CoreCLR") { + File.WriteAllText (rules, "# legacy rules"); + } + Assert.DoesNotContain ("-keep class test.Live", File.ReadAllText (rules)); + Build (project, "-p:_AndroidEnableTypemapR8Trimming=" + enabled); + Assert.Equal ("-keep class test.Live\n", File.ReadAllText (rules)); + Assert.Contains ("UseTypeMap=true", File.ReadAllText (Path.Combine (directory, "writes.txt"))); + } + } + + [Theory] + [InlineData ("false", "false", false)] + [InlineData ("false", "true", true)] + [InlineData ("true", "false", true)] + public void DisablingAutomaticDgmlPreservesExplicitDiagnostics (string enabled, string diagnostics, bool serialBuild) + { + var project = CreateProject ("NativeAOT", "trimmable"); + var document = XDocument.Load (project); + var root = document.Root ?? throw new InvalidOperationException (); + root.Add (new XElement ("Import", new XAttribute ("Project", + Path.Combine (RepositoryDirectory (), "src", "Xamarin.Android.Build.Tasks", "Microsoft.Android.Sdk", "targets", "Microsoft.Android.Sdk.TypeMap.Trimmable.NativeAOT.targets")))); + root.Add (new XElement ("Target", new XAttribute ("Name", "_ReadGeneratedTrimmableTypeMapAssemblies"))); + root.Add (new XElement ("Target", new XAttribute ("Name", "Build"), + new XAttribute ("DependsOnTargets", "_AddTrimmableTypeMapAssembliesToIlc"), + new XElement ("WriteLinesToFile", new XAttribute ("File", "$(MSBuildProjectDirectory)/ilc.txt"), + new XAttribute ("Lines", "@(IlcArg);Diagnostics=$(IlcGenerateDgmlFile);Parallel=$(_AndroidBuildRuntimeIdentifiersInParallel)"), + new XAttribute ("Overwrite", "true")))); + document.Save (project); + Build (project, "-p:_AndroidEnableTypemapR8Trimming=" + enabled, "-p:IlcGenerateDgmlFile=" + diagnostics, "-p:Optimize=true"); + var output = File.ReadAllText (Path.Combine (directory, "ilc.txt")); + Assert.Equal (enabled == "true" && diagnostics == "false", output.Contains ("--scandgmllog:", StringComparison.Ordinal)); + Assert.Equal (serialBuild, output.Contains ("Parallel=false", StringComparison.Ordinal)); + Assert.Contains ("Diagnostics=" + diagnostics, output); + } + [Fact] public void InnerBuildDoesNotGenerateOuterClassRules () { @@ -240,7 +313,7 @@ public void InnerBuildReturnsExactProducerPaths (string runtime, string optimize public void InactivePathsDoNotConsumeOrGenerateKeys (string runtime, string representation, string trimmed, string linkTool) { var project = CreateProject (runtime, representation); - Build (project, $"-p:PublishTrimmed={trimmed}", $"-p:AndroidLinkTool={linkTool}"); + Build (project, $"-p:PublishTrimmed={trimmed}", $"-p:AndroidLinkTool={linkTool}", "-p:_AndroidEnableTypemapR8Trimming=true"); Assert.False (File.Exists (Path.Combine (directory, "obj", "typemap.keys.txt"))); } @@ -273,13 +346,18 @@ string CreateProject (string runtime, string representation, params XElement [] new XElement ("AndroidLinkTool", "r8"), new XElement ("_AndroidTrimmableTypemapTrimJavaCode", "true"), new XElement ("IntermediateOutputPath", "$(MSBuildProjectDirectory)/obj/"), + new XElement ("_AndroidBuildPropertiesCache", "$(MSBuildProjectDirectory)/obj/build.props.cache"), new XElement ("_AcwMapFile", "$(MSBuildProjectDirectory)/acw-map.txt")), new XElement ("Import", new XAttribute ("Project", targets)), new XElement ("Target", new XAttribute ("Name", "_GenerateJavaStubs"), new XElement ("ItemGroup", sourceItems)), new XElement ("Target", new XAttribute ("Name", "_CalculateProguardConfigurationFiles")), + new XElement ("Target", new XAttribute ("Name", "_CreatePropertiesCache"), + new XElement ("WriteLinesToFile", new XAttribute ("File", "$(_AndroidBuildPropertiesCache)"), + new XAttribute ("Lines", "Enabled=$(_AndroidEnableTypemapR8Trimming)"), + new XAttribute ("Overwrite", "true"), new XAttribute ("WriteOnlyWhenDifferent", "true"))), new XElement ("Target", new XAttribute ("Name", "Build"), - new XAttribute ("DependsOnTargets", "_CalculateProguardConfigurationFiles;_AndroidGenerateTypeMapProguardConfiguration"), + new XAttribute ("DependsOnTargets", "_CreatePropertiesCache;_CalculateProguardConfigurationFiles;_AndroidGenerateTypeMapProguardConfiguration"), new XElement ("WriteLinesToFile", new XAttribute ("File", "$(MSBuildProjectDirectory)/writes.txt"), new XAttribute ("Lines", "@(FileWrites);UseTypeMap=$(_AndroidUseTypeMapProguardConfiguration)"), new XAttribute ("Overwrite", "true"))))) .Save (path); From ce8875917c1d010bd36b76d71383545413b0cbe7 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 18 Sep 2026 09:30:28 +0200 Subject: [PATCH 17/25] Replace legacy NativeAOT typemap controls with the shared pipeline Use _AndroidEnableTypemapR8Trimming as the sole product switch. Consume DGML only through retained-key extraction; retain optimized-only automatic selection and explicit codegen graph opt-in without the old NativeAOT switches. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Documentation/guides/D8andR8.md | 9 ++++--- ...oft.Android.Sdk.AssemblyResolution.targets | 6 ++--- ...osoft.Android.Sdk.TypeMap.Proguard.targets | 16 +++++------- ...id.Sdk.TypeMap.Trimmable.NativeAOT.targets | 18 ++++--------- .../Xamarin.Android.Common.targets | 2 -- .../Generator/TypeMapProguardTests.cs | 26 ++++++++++++++++--- 6 files changed, 44 insertions(+), 33 deletions(-) diff --git a/Documentation/guides/D8andR8.md b/Documentation/guides/D8andR8.md index 4ac8ea94245..b2829f9202d 100644 --- a/Documentation/guides/D8andR8.md +++ b/Documentation/guides/D8andR8.md @@ -15,8 +15,8 @@ every class in the ACW map: Inner builds return their exact source paths to the outer build. Keys are unioned across the requested RIDs/ABIs; stale files from other builds are not discovered by globbing. NativeAOT uses the scan graph in optimized builds and -the codegen graph when the scanner is disabled. Its existing Java-trimming -opt-out instead extracts every ACW class without requiring a graph. +the codegen graph when the scanner is disabled. DGML is consumed only by the +retained-keys adapter, not by a separate NativeAOT rule generator. All three adapters and the shared generator are `Microsoft.Android.Tasks` tasks in `Microsoft.Android.Build.Tasks.dll`. @@ -51,12 +51,15 @@ whole wrapper packages. User Java source retention and application/library ProGuard rules remain separate. The temporary private override `_AndroidEnableTypemapR8Trimming` controls this -pipeline. Leave it unset for the automatic behavior, set it to `true` to enable +pipeline. It replaces the old NativeAOT-specific trimming and ProGuard switches. +Leave it unset for the automatic behavior, set it to `true` to enable the pipeline in eligible managed-trimmed CoreCLR/NativeAOT R8 builds, or set it to `false` to use legacy ACW retention without running the new tasks. Disabling it also avoids automatic NativeAOT DGML generation, but does not suppress explicit `IlcGenerateDgmlFile` diagnostics. It does not enable R8 or managed trimming in otherwise ineligible builds. +Automatic NativeAOT selection requires an optimized build; unoptimized builds +require explicit `true` to avoid generating large codegen graphs by default. This pipeline disables all obfuscation, including private-member obfuscation, with both `-dontobfuscate` and R8's `--no-minification` option. 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 a906cdb0af2..39d33ed999f 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 @@ -77,10 +77,10 @@ _ResolveAssemblies MSBuild target. <_AndroidLinkedTypeMapKeySource Include="@(_GeneratedTypeMapAssembliesFromList->'$(IntermediateOutputPath)linked\%(Filename)%(Extension)')" Condition=" '$(_AndroidEnableTypemapR8Trimming)' != 'false' and '$(_AndroidRuntime)' == 'CoreCLR' and '$(AndroidTypeMapImplementation)' == 'trimmable' and '$(PublishTrimmed)' == 'true' " /> @@ -95,7 +95,7 @@ _ResolveAssemblies MSBuild target. RuntimeIdentifier="$(RuntimeIdentifier)" /> - + - <_AndroidUseTypeMapProguardConfiguration Condition=" '$(ProguardConfigFiles)' == '' and ('$(_AndroidRuntime)' != 'NativeAOT' or '$(_UseTrimmableNativeAotProguardConfiguration)' != 'false') ">true + + <_AndroidUseTypeMapProguardConfiguration Condition=" '$(ProguardConfigFiles)' == '' ">true @@ -34,7 +33,7 @@ @@ -62,7 +61,7 @@ <_AndroidTypeMapDgmlSource Include="@(ResolvedFileToPublish->'%(AndroidTypeMapDgmlFile)'->Distinct())" - Condition=" '$(_AndroidTypeMapKeysKind)' == 'dgml' and '$(_AndroidTrimmableTypemapTrimJavaCode)' == 'true' " /> + Condition=" '$(_AndroidTypeMapKeysKind)' == 'dgml' " /> <_AndroidTypeMapKeySource Include="@(_AndroidTypeMapDgmlSource);$(_AcwMapFile)" Condition=" '$(_AndroidTypeMapKeysKind)' == 'dgml' " /> <_AndroidTypeMapKeySource Include="@(_TypeMapAssemblySource->Distinct())" @@ -70,7 +69,7 @@ <_AndroidTypeMapKeySource Include="$(_AndroidTypeMapLinkedAssemblies)" Condition=" '$(_AndroidTypeMapKeysKind)' == 'assemblies' " /> <_AndroidTypeMapKeysState Remove="@(_AndroidTypeMapKeysState)" /> - <_AndroidTypeMapKeysState Include="Runtime=$(_AndroidRuntime);Representation=$(_AndroidTypeMapKeysKind);TrimJava=$(_AndroidTrimmableTypemapTrimJavaCode);Optimize=$(Optimize);RuntimeIdentifier=$(RuntimeIdentifier);RuntimeIdentifiers=$(RuntimeIdentifiers);TypemapPolicy=$(_AndroidUseTypeMapProguardConfiguration);Enabled=$(_AndroidEnableTypemapR8Trimming)" /> + <_AndroidTypeMapKeysState Include="Runtime=$(_AndroidRuntime);Representation=$(_AndroidTypeMapKeysKind);Optimize=$(Optimize);RuntimeIdentifier=$(RuntimeIdentifier);RuntimeIdentifiers=$(RuntimeIdentifiers);TypemapPolicy=$(_AndroidUseTypeMapProguardConfiguration);Enabled=$(_AndroidEnableTypemapR8Trimming)" /> <_AndroidTypeMapKeysState Include="@(_AndroidTypeMapKeySource->'%(FullPath)')" /> - <_AndroidTrimmableTypemapTrimJavaCode Condition=" '$(_AndroidTrimmableTypemapTrimJavaCode)' == '' and '$(Optimize)' == 'true' ">true - <_AndroidTrimmableTypemapTrimJavaCode Condition=" '$(_AndroidTrimmableTypemapTrimJavaCode)' == '' ">false + Serialize the inner builds while the shared typemap pipeline or full DGML diagnostics are active. + Unoptimized builds require explicit opt-in because their codegen graphs are much larger. --> <_AndroidBuildRuntimeIdentifiersInParallel - Condition=" (('$(_AndroidEnableTypemapR8Trimming)' != 'false' and '$(_AndroidTrimmableTypemapTrimJavaCode)' == 'true') or '$(IlcGenerateDgmlFile)' == 'true') and '$(_AndroidBuildRuntimeIdentifiersInParallel)' == '' ">false + Condition=" (('$(_AndroidEnableTypemapR8Trimming)' != 'false' and ('$(Optimize)' == 'true' or '$(_AndroidEnableTypemapR8Trimming)' == 'true')) or '$(IlcGenerateDgmlFile)' == 'true') and '$(_AndroidBuildRuntimeIdentifiersInParallel)' == '' ">false <_TrimmableRuntimeProviderJavaName Condition=" '$(_TrimmableRuntimeProviderJavaName)' == '' ">net.dot.jni.nativeaot.NativeAotRuntimeProvider r8 d8 True True - <_UseTrimmableNativeAotProguardConfiguration Condition=" '$(_UseTrimmableNativeAotProguardConfiguration)' == '' ">true diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets index 5827cd43038..4300eaad120 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets @@ -1005,9 +1005,7 @@ because xbuild doesn't support framework reference assemblies. <_PropertyCacheItems Include="_AndroidRuntime=$(_AndroidRuntime)" /> <_PropertyCacheItems Include="PublishTrimmed=$(PublishTrimmed)" /> <_PropertyCacheItems Include="Optimize=$(Optimize)" /> - <_PropertyCacheItems Include="_AndroidTrimmableTypemapTrimJavaCode=$(_AndroidTrimmableTypemapTrimJavaCode)" /> <_PropertyCacheItems Include="_AndroidEnableTypemapR8Trimming=$(_AndroidEnableTypemapR8Trimming)" /> - <_PropertyCacheItems Include="_UseTrimmableNativeAotProguardConfiguration=$(_UseTrimmableNativeAotProguardConfiguration)" /> <_PropertyCacheItems Include="ProguardConfigFiles=$(ProguardConfigFiles)" /> <_PropertyCacheItems Include="_AndroidUseMarshalMethods=$(_AndroidUseMarshalMethods)" /> <_PropertyCacheItems Include="_AndroidJcwCodegenTarget=$(_AndroidJcwCodegenTarget)" /> diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs index d42cef54551..c34d2c9a99e 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs @@ -119,8 +119,8 @@ public void DgmlTargetUnionsRequestedGraphsAndHonorsDisabledTrimming () Build (project); var rules = Path.Combine (directory, "obj", "proguard", "proguard_project_references.cfg"); Assert.Equal ("-keep class test.Live\n-keep class test.Second\n", File.ReadAllText (rules)); - Build (project, "-p:_AndroidTrimmableTypemapTrimJavaCode=false"); - Assert.Equal ("-keep class test.Dead\n-keep class test.Live\n-keep class test.Second\n", File.ReadAllText (rules)); + Build (project, "-p:_AndroidEnableTypemapR8Trimming=false"); + Assert.Contains ("legacy ACW configuration", File.ReadAllText (rules)); File.Delete (Path.Combine (directory, "second.dgml")); var output = Build (project, expectSuccess: false); Assert.Contains ("XA4321", output); @@ -230,6 +230,25 @@ public void DisablingAutomaticDgmlPreservesExplicitDiagnostics (string enabled, Assert.Contains ("Diagnostics=" + diagnostics, output); } + [Fact] + public void UnoptimizedNativeAotRequiresExplicitPipelineOptIn () + { + Write ("app.dgml", """"""); + Write ("acw-map.txt", "App.Live, App;test.Live\nApp.Dead, App;test.Dead\n"); + var project = CreateProject ("NativeAOT", "trimmable", + new XElement ("ResolvedFileToPublish", new XAttribute ("Include", "app.so"), + new XAttribute ("AndroidTypeMapDgmlFile", "$(MSBuildProjectDirectory)/app.dgml"))); + var keys = Path.Combine (directory, "obj", "typemap.keys.txt"); + var rules = Path.Combine (directory, "obj", "proguard", "proguard_project_references.cfg"); + Build (project, "-p:Optimize=false"); + Assert.False (File.Exists (keys)); + Assert.Contains ("legacy ACW configuration", File.ReadAllText (rules)); + Build (project, "-p:Optimize=false", "-p:_AndroidEnableTypemapR8Trimming=true"); + Assert.Equal ("-keep class test.Live\n", File.ReadAllText (rules)); + Build (project, "-p:Optimize=false"); + Assert.Contains ("legacy ACW configuration", File.ReadAllText (rules)); + } + [Fact] public void InnerBuildDoesNotGenerateOuterClassRules () { @@ -286,6 +305,7 @@ public void InnerBuildReturnsExactProducerPaths (string runtime, string optimize new XElement ("NativeIntermediateOutputPath", "$(MSBuildProjectDirectory)/custom-native/"), new XElement ("TargetName", "App"), new XElement ("Optimize", optimize), + new XElement ("_AndroidEnableTypemapR8Trimming", "true"), new XElement ("_TypeMapAssemblyName", "_Microsoft.Android.TypeMaps"))); foreach (var target in new [] { "BuildOnlySettings", "_CheckForInvalidConfigurationAndPlatform", "_FixupIntermediateAssembly", "_PatchNuGetReferenceMetadata", "ResolveReferences", "_AndroidAot" }) { root.Add (new XElement ("Target", new XAttribute ("Name", target))); @@ -344,7 +364,7 @@ string CreateProject (string runtime, string representation, params XElement [] new XElement ("AndroidTypeMapImplementation", representation), new XElement ("PublishTrimmed", "true"), new XElement ("AndroidLinkTool", "r8"), - new XElement ("_AndroidTrimmableTypemapTrimJavaCode", "true"), + new XElement ("Optimize", "true"), new XElement ("IntermediateOutputPath", "$(MSBuildProjectDirectory)/obj/"), new XElement ("_AndroidBuildPropertiesCache", "$(MSBuildProjectDirectory)/obj/build.props.cache"), new XElement ("_AcwMapFile", "$(MSBuildProjectDirectory)/acw-map.txt")), From e1f8869f739947d39c0bbae497fea5b2bbe49c50 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 18 Sep 2026 09:41:50 +0200 Subject: [PATCH 18/25] Handle JNI array entries in retained LLVM typemaps Map object arrays to their canonical element class and omit primitive arrays, which do not represent Java class definitions. Keep malformed descriptors and numeric aliases invalid for LLVM inputs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tasks/ExtractTypeMapKeysFromLlvmIr.cs | 16 +--- .../ExtractTypeMapKeysFromLlvmIrTests.cs | 11 ++- .../Utilities/TypeMapClassName.cs | 31 ++++++++ .../ExtractTypeMapKeysFromLlvmIrArrayTests.cs | 77 +++++++++++++++++++ 4 files changed, 122 insertions(+), 13 deletions(-) create mode 100644 src/Microsoft.Android.Build.Tasks/Utilities/TypeMapClassName.cs create mode 100644 tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromLlvmIrArrayTests.cs diff --git a/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromLlvmIr.cs b/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromLlvmIr.cs index 2f6fa8be448..811f6a2b9cd 100644 --- a/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromLlvmIr.cs +++ b/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromLlvmIr.cs @@ -216,19 +216,11 @@ void AddByte (byte b) } string name = Utf8.GetString (nameBytes.ToArray ()); nameBytes.Clear (); - ValidateName (name); - keys.Add (name); - } - - void ValidateName (string name) - { - if (name.Length == 0 || name [0] == '/' || name [name.Length - 1] == '/' || name.Contains ("//")) { - throw Invalid ("An empty Java class name or package segment was found."); + if (!TypeMapClassName.TryGetClassName (name, out var className)) { + throw Invalid ($"Invalid Java type name '{name}' in the blob."); } - foreach (char c in name) { - if (Char.IsWhiteSpace (c) || Char.IsControl (c) || ".;[]\\\"'*!?:,{}()#@<>%".IndexOf (c) >= 0) { - throw Invalid ("A noncanonical Java class name was found in the blob."); - } + if (className != null) { + keys.Add (className); } } diff --git a/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/ExtractTypeMapKeysFromLlvmIrTests.cs b/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/ExtractTypeMapKeysFromLlvmIrTests.cs index 62c2562f463..04984ef9312 100644 --- a/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/ExtractTypeMapKeysFromLlvmIrTests.cs +++ b/src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/ExtractTypeMapKeysFromLlvmIrTests.cs @@ -167,7 +167,7 @@ public void RejectsUnsupportedOrMalformedInput (string source) [TestCase ("")] [TestCase ("test/Type[1]")] - [TestCase ("[Ltest/Type;")] + [TestCase ("[Ltest/Type")] [TestCase ("test.Type")] [TestCase ("test//Type")] [TestCase ("/Type")] @@ -191,6 +191,15 @@ public void RejectsInvalidClassNames (string name) Assert.That (File.Exists (task.OutputFile), Is.False); } + [Test] + public void ReadsArrayDescriptors ([Values (ReleaseSymbol, DebugSymbol)] string symbol) + { + string [] names = ["[Ljava/lang/Object;", "[Z", "[B", "[C", "[S", "[I", "[J", "[F", "[D", "[[Ltest/Outer$Inner;", "test/Outer$Inner"]; + var task = CreateTask (WriteEmittedBlob ("arrays.ll", symbol, names, false, AndroidTargetArch.Arm64)); + Assert.IsTrue (task.Execute ()); + AssertOutput (task.OutputFile, ["java/lang/Object", "test/Outer$Inner"]); + } + [Test] public void RequiresEverySourceFile () { diff --git a/src/Microsoft.Android.Build.Tasks/Utilities/TypeMapClassName.cs b/src/Microsoft.Android.Build.Tasks/Utilities/TypeMapClassName.cs new file mode 100644 index 00000000000..535f168f7e0 --- /dev/null +++ b/src/Microsoft.Android.Build.Tasks/Utilities/TypeMapClassName.cs @@ -0,0 +1,31 @@ +namespace Microsoft.Android.Tasks; + +internal static class TypeMapClassName +{ + public static bool TryGetClassName (string name, out string? className) + { + className = null; + int dimensions = 0; + while (dimensions < name.Length && name [dimensions] == '[') { + dimensions++; + } + if (dimensions > 0) { + if (dimensions > 255 || dimensions == name.Length) { + return false; + } + // Primitive arrays are valid typemap entries but do not name a Java class to keep. + if (dimensions == name.Length - 1 && "BCDFIJSZ".IndexOf (name [dimensions]) >= 0) { + return true; + } + if (name [dimensions] != 'L' || name [name.Length - 1] != ';') { + return false; + } + name = name.Substring (dimensions + 1, name.Length - dimensions - 2); + } + if (!GenerateTypeMapProguardConfiguration.IsClassName (name)) { + return false; + } + className = name; + return true; + } +} diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromLlvmIrArrayTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromLlvmIrArrayTests.cs new file mode 100644 index 00000000000..8dd512fcb9d --- /dev/null +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromLlvmIrArrayTests.cs @@ -0,0 +1,77 @@ +using System; +using System.Globalization; +using System.IO; +using System.Text; +using Microsoft.Android.Tasks; +using Microsoft.Build.Utilities; +using Xunit; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap.Tests; + +public class ExtractTypeMapKeysFromLlvmIrArrayTests : IDisposable +{ + readonly string directory = Path.Combine (Path.GetTempPath (), "llvm-array-typemap-" + Guid.NewGuid ().ToString ("N")); + readonly TypeMapTaskBuildEngine engine = new (); + + public ExtractTypeMapKeysFromLlvmIrArrayTests () => Directory.CreateDirectory (directory); + + [Theory] + [InlineData ("java_type_names")] + [InlineData ("type_map_java_type_names")] + public void ArraysContributeOnlyReferenceElementClasses (string symbol) + { + var task = CreateTask (symbol, "[Ljava/lang/Object;", "[Z", "[B", "[C", "[S", "[I", "[J", "[F", "[D", + "[[[I", "test/Live", "[[Ltest/Outer$Inner;", "test/Outer$Inner"); + Assert.True (task.Execute ()); + Assert.Empty (engine.Errors); + Assert.Equal ("java/lang/Object\ntest/Live\ntest/Outer$Inner\n", File.ReadAllText (task.OutputFile)); + } + + [Fact] + public void PrimitiveArraysAloneProduceAValidEmptyClassSet () + { + var task = CreateTask ("java_type_names", "[B", "[[I", "[[[D"); + Assert.True (task.Execute ()); + Assert.Empty (File.ReadAllBytes (task.OutputFile)); + } + + [Theory] + [InlineData ("[")] + [InlineData ("[[")] + [InlineData ("[V")] + [InlineData ("[Q")] + [InlineData ("[Iextra")] + [InlineData ("[[I;")] + [InlineData ("[L;")] + [InlineData ("[Ljava.lang.Object;")] + [InlineData ("[Ljava/lang/Object")] + [InlineData ("[Ljava/lang/Object;;")] + [InlineData ("[Ltest/*;")] + [InlineData ("[Ltest/Foo[0];")] + [InlineData ("Ltest/Foo;")] + public void MalformedDescriptorsStillFail (string name) + { + var task = CreateTask ("java_type_names", name); + Assert.False (task.Execute ()); + Assert.Contains (engine.Errors, error => error.Code == "XA4327"); + Assert.False (File.Exists (task.OutputFile)); + } + + ExtractTypeMapKeysFromLlvmIr CreateTask (string symbol, params string [] names) + { + var bytes = Encoding.UTF8.GetBytes (string.Join ('\0', names) + '\0'); + var literal = new StringBuilder (); + foreach (var value in bytes) { + literal.Append ('\\').Append (value.ToString ("X2", CultureInfo.InvariantCulture)); + } + var source = Path.Combine (directory, "typemaps.ll"); + File.WriteAllText (source, $"@{symbol} = dso_local local_unnamed_addr constant [{bytes.Length} x i8] c\"{literal}\", align 1\n"); + return new ExtractTypeMapKeysFromLlvmIr { + BuildEngine = engine, + LlvmIrFiles = [new TaskItem (source)], + OutputFile = Path.Combine (directory, "classes.keys"), + }; + } + + public void Dispose () => Directory.Delete (directory, recursive: true); +} From c804d4295330c09d0865b18700edced04cdf5d53 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 18 Sep 2026 09:53:02 +0200 Subject: [PATCH 19/25] Accept intrinsic type metadata in NativeAOT DGML ILC emits built-in types such as int32 without assembly-qualified names. Ignore the known intrinsic labels while preserving strict validation of malformed managed identities. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tasks/ExtractTypeMapKeysFromDgml.cs | 23 +++++++++++++ .../ExtractTypeMapKeysFromDgmlTests.cs | 32 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromDgml.cs b/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromDgml.cs index 6fb1b0cd981..5ff5dc8dd67 100644 --- a/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromDgml.cs +++ b/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromDgml.cs @@ -183,6 +183,10 @@ static void LoadRetainedTypeKeysFromDgml (string path, HashSet typeKeys) } if (!label.StartsWith (TypeMetadataPrefix, StringComparison.Ordinal)) { + // ILC renders built-in types as keywords, not assembly-qualified ACW identities. + if (IsIntrinsicTypeMetadata (label)) { + continue; + } throw new XmlException ($"Invalid NativeAOT type metadata label '{label}'."); } var assemblyStart = TypeMetadataPrefix.Length; @@ -199,4 +203,23 @@ static void LoadRetainedTypeKeysFromDgml (string path, HashSet typeKeys) typeKeys.Add ($"{managedTypeName}, {assemblyName}"); } } + + static bool IsIntrinsicTypeMetadata (string label) => label is + "Type metadata: void" or + "Type metadata: bool" or + "Type metadata: char" or + "Type metadata: int8" or + "Type metadata: uint8" or + "Type metadata: int16" or + "Type metadata: uint16" or + "Type metadata: int32" or + "Type metadata: uint32" or + "Type metadata: int64" or + "Type metadata: uint64" or + "Type metadata: native int" or + "Type metadata: native uint" or + "Type metadata: float32" or + "Type metadata: float64" or + "Type metadata: string" or + "Type metadata: object"; } diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromDgmlTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromDgmlTests.cs index 73eabcfb14b..e4f58467b6c 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromDgmlTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromDgmlTests.cs @@ -286,6 +286,38 @@ public void Execute_RejectsMalformedOrUnsupportedGraph (string graph) Assert.False (File.Exists (task.OutputFile), "A valid first graph must not result in partial output."); } + [Theory] + [InlineData ("void")] + [InlineData ("bool")] + [InlineData ("char")] + [InlineData ("int8")] + [InlineData ("uint8")] + [InlineData ("int16")] + [InlineData ("uint16")] + [InlineData ("int32")] + [InlineData ("uint32")] + [InlineData ("int64")] + [InlineData ("uint64")] + [InlineData ("native int")] + [InlineData ("native uint")] + [InlineData ("float32")] + [InlineData ("float64")] + [InlineData ("string")] + [InlineData ("object")] + public void Execute_IgnoresIntrinsicTypeMetadata (string type) + { + var (task, errors) = CreateTask ("Example.Type, App;example.Type", true, + $""" + + + + + """); + Assert.True (task.Execute ()); + Assert.Empty (errors); + Assert.Equal ("example/Type\n", File.ReadAllText (task.OutputFile)); + } + [Theory] [InlineData ("Type metadata: [App")] [InlineData ("Type metadata: []Example.Type")] From 85e3698b0c5d0f56694b610a73c4ae1d0ee5c823 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 18 Sep 2026 09:54:54 +0200 Subject: [PATCH 20/25] Extract retained NativeAOT typemap keys from native objects Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tasks/ExtractTypeMapKeysFromAssemblies.cs | 22 +- .../Tasks/ExtractTypeMapKeysFromDgml.cs | 225 ------- .../ExtractTypeMapKeysFromNativeAotObject.cs | 167 +++++ .../Utilities/NativeAotTypeMapReader.cs | 149 +++++ .../Utilities/TypeMapKey.cs | 18 + .../ExtractTypeMapKeysFromAssembliesTests.cs | 19 + .../ExtractTypeMapKeysFromDgmlTests.cs | 441 ------------- ...ractTypeMapKeysFromNativeAotObjectTests.cs | 579 ++++++++++++++++++ .../NativeAotObjectTestFixture.Tool.cs | 50 ++ .../Generator/NativeAotObjectTestFixture.cs | 101 +++ 10 files changed, 1088 insertions(+), 683 deletions(-) delete mode 100644 src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromDgml.cs create mode 100644 src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromNativeAotObject.cs create mode 100644 src/Microsoft.Android.Build.Tasks/Utilities/NativeAotTypeMapReader.cs create mode 100644 src/Microsoft.Android.Build.Tasks/Utilities/TypeMapKey.cs delete mode 100644 tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromDgmlTests.cs create mode 100644 tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromNativeAotObjectTests.cs create mode 100644 tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/NativeAotObjectTestFixture.Tool.cs create mode 100644 tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/NativeAotObjectTestFixture.cs diff --git a/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromAssemblies.cs b/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromAssemblies.cs index ac7c7e4b843..4de720cf03c 100644 --- a/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromAssemblies.cs +++ b/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromAssemblies.cs @@ -98,11 +98,13 @@ void ReadKeys (string assemblyPath, SortedSet keys) throw new BadImageFormatException ("Invalid TypeMap attribute arguments."); } - key = NormalizeKey (key); - if (!GenerateTypeMapProguardConfiguration.IsClassName (key)) { + key = TypeMapKey.NormalizeAliasKey (key); + if (!TypeMapClassName.TryGetClassName (key, out string? className)) { throw new BadImageFormatException ($"Invalid TypeMap class name '{key}'."); } - keys.Add (key); + if (className != null) { + keys.Add (className); + } } } @@ -154,19 +156,5 @@ static int GetArgumentCount (MetadataReader reader, CustomAttribute attribute) } return count; } - - static string NormalizeKey (string key) - { - int start = key.LastIndexOf ('['); - if (start <= 0 || start == key.Length - 2 || key [key.Length - 1] != ']') { - return key; - } - for (int i = start + 1; i < key.Length - 1; i++) { - if (key [i] < '0' || key [i] > '9') { - return key; - } - } - return key.Substring (0, start); - } } } diff --git a/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromDgml.cs b/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromDgml.cs deleted file mode 100644 index 5ff5dc8dd67..00000000000 --- a/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromDgml.cs +++ /dev/null @@ -1,225 +0,0 @@ -#nullable enable - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Text; -using System.Xml; -using Microsoft.Build.Framework; -using Microsoft.Android.Build.Tasks; -using Xamarin.Android.Tasks; -using Properties = Xamarin.Android.Tasks.Properties; - -namespace Microsoft.Android.Tasks; - -public class ExtractTypeMapKeysFromDgml : AndroidTask -{ - const string DgmlNamespace = "http://schemas.microsoft.com/vs/2009/dgml"; - const string TypeMetadataPrefix = "Type metadata: ["; - - public override string TaskPrefix => "ETMKD"; - - public ITaskItem [] NativeAotDgmlFiles { get; set; } = []; - - [Required] - public string AcwMapFile { get; set; } = ""; - - public bool TrimJavaCallableWrappers { get; set; } = true; - - [Required] - public string OutputFile { get; set; } = ""; - - public override bool RunTask () - { - if (!File.Exists (AcwMapFile)) { - Log.LogCodedError ("XA4320", Properties.Resources.XA4320, AcwMapFile); - return false; - } - - HashSet? retainedTypeKeys = null; - if (TrimJavaCallableWrappers) { - if (NativeAotDgmlFiles.Length == 0) { - Log.LogCodedError ("XA4319", Properties.Resources.XA4319); - return false; - } - retainedTypeKeys = new HashSet (StringComparer.Ordinal); - foreach (var dgmlFile in NativeAotDgmlFiles) { - if (!File.Exists (dgmlFile.ItemSpec)) { - Log.LogCodedError ("XA4321", Properties.Resources.XA4321, dgmlFile.ItemSpec); - return false; - } - try { - LoadRetainedTypeKeysFromDgml (dgmlFile.ItemSpec, retainedTypeKeys); - } catch (Exception e) when (e is XmlException || e is IOException || e is UnauthorizedAccessException) { - Log.LogCodedError ("XA4327", Properties.Resources.XA4327, dgmlFile.ItemSpec, e.Message); - return false; - } - } - } - - SortedSet javaTypes; - try { - javaTypes = LoadJavaTypesFromAcwMap (retainedTypeKeys); - } catch (Exception e) when (e is FormatException || e is IOException || e is UnauthorizedAccessException || e is DecoderFallbackException) { - Log.LogCodedError ("XA4327", Properties.Resources.XA4327, AcwMapFile, e.Message); - return false; - } - - var directory = Path.GetDirectoryName (OutputFile); - if (!directory.IsNullOrEmpty ()) { - Directory.CreateDirectory (directory); - } - using (var writer = new StreamWriter (OutputFile, false, new UTF8Encoding (false, true)) { NewLine = "\n" }) { - foreach (var javaType in javaTypes) { - writer.WriteLine (javaType); - } - } - - Log.LogMessage (MessageImportance.Low, "Extracted {0} retained Java type map keys from the NativeAOT ACW map.", javaTypes.Count); - return !Log.HasLoggedErrors; - } - - SortedSet LoadJavaTypesFromAcwMap (HashSet? retainedTypeKeys) - { - var javaTypes = new SortedSet (StringComparer.Ordinal); - int lineNumber = 0; - foreach (var line in File.ReadLines (AcwMapFile, new UTF8Encoding (true, true))) { - lineNumber++; - if (line.Length == 0) { - continue; - } - var separator = line.IndexOf (';'); - if (separator <= 0 || separator == line.Length - 1) { - throw new FormatException ($"ACW map line {lineNumber} must contain a managed key and a Java class name separated by ';'."); - } - - var managedTypeName = line.Substring (0, separator); - var javaTypeName = line.Substring (separator + 1).Replace ('.', '/'); - if (managedTypeName.IsNullOrWhiteSpace () || !IsJavaClassName (javaTypeName)) { - throw new FormatException ($"ACW map line {lineNumber} contains an invalid managed key or Java class name."); - } - - // Null means Java trimming is disabled, so even the unqualified ACW entries are retained. - if (retainedTypeKeys == null || retainedTypeKeys.Contains (managedTypeName)) { - javaTypes.Add (javaTypeName); - } - } - return javaTypes; - } - - static bool IsJavaClassName (string name) - { - bool segmentStart = true; - for (int i = 0; i < name.Length; i++) { - if (name [i] == '/') { - if (segmentStart) { - return false; - } - segmentStart = true; - continue; - } - - var category = CharUnicodeInfo.GetUnicodeCategory (name, i); - switch (category) { - case UnicodeCategory.UppercaseLetter: - case UnicodeCategory.LowercaseLetter: - case UnicodeCategory.TitlecaseLetter: - case UnicodeCategory.ModifierLetter: - case UnicodeCategory.OtherLetter: - case UnicodeCategory.LetterNumber: - case UnicodeCategory.CurrencySymbol: - case UnicodeCategory.ConnectorPunctuation: - break; - case UnicodeCategory.DecimalDigitNumber: - case UnicodeCategory.NonSpacingMark: - case UnicodeCategory.SpacingCombiningMark: - if (segmentStart) { - return false; - } - break; - default: - return false; - } - - if (char.IsHighSurrogate (name [i])) { - i++; - } - segmentStart = false; - } - return !segmentStart; - } - - static void LoadRetainedTypeKeysFromDgml (string path, HashSet typeKeys) - { - using var reader = XmlReader.Create (path, new XmlReaderSettings { - DtdProcessing = DtdProcessing.Prohibit, - XmlResolver = null, - }); - - if (reader.MoveToContent () != XmlNodeType.Element || reader.LocalName != "DirectedGraph" || - (reader.NamespaceURI.Length != 0 && reader.NamespaceURI != DgmlNamespace)) { - throw new XmlException ("Expected a DGML DirectedGraph document."); - } - - string graphNamespace = reader.NamespaceURI; - bool readingNodes = false; - // Read through EOF, not just , so a truncated graph never looks like a valid retained set. - while (reader.Read ()) { - if (reader.NamespaceURI != graphNamespace) { - continue; - } - if (reader.Depth == 1 && reader.LocalName == "Nodes") { - readingNodes = reader.NodeType == XmlNodeType.Element && !reader.IsEmptyElement; - continue; - } - if (!readingNodes || reader.Depth != 2 || reader.NodeType != XmlNodeType.Element || reader.LocalName != "Node") { - continue; - } - - var label = reader.GetAttribute ("Label"); - if (label.IsNullOrEmpty () || !label.StartsWith ("Type metadata:", StringComparison.Ordinal)) { - continue; - } - - if (!label.StartsWith (TypeMetadataPrefix, StringComparison.Ordinal)) { - // ILC renders built-in types as keywords, not assembly-qualified ACW identities. - if (IsIntrinsicTypeMetadata (label)) { - continue; - } - throw new XmlException ($"Invalid NativeAOT type metadata label '{label}'."); - } - var assemblyStart = TypeMetadataPrefix.Length; - var assemblyEnd = label.IndexOf (']', assemblyStart); - if (assemblyEnd <= assemblyStart || assemblyEnd == label.Length - 1) { - throw new XmlException ($"Invalid NativeAOT type metadata label '{label}'."); - } - - var assemblyName = label.Substring (assemblyStart, assemblyEnd - assemblyStart); - var managedTypeName = label.Substring (assemblyEnd + 1); - if (assemblyName.IsNullOrWhiteSpace () || managedTypeName.IsNullOrWhiteSpace ()) { - throw new XmlException ($"Invalid NativeAOT type metadata label '{label}'."); - } - typeKeys.Add ($"{managedTypeName}, {assemblyName}"); - } - } - - static bool IsIntrinsicTypeMetadata (string label) => label is - "Type metadata: void" or - "Type metadata: bool" or - "Type metadata: char" or - "Type metadata: int8" or - "Type metadata: uint8" or - "Type metadata: int16" or - "Type metadata: uint16" or - "Type metadata: int32" or - "Type metadata: uint32" or - "Type metadata: int64" or - "Type metadata: uint64" or - "Type metadata: native int" or - "Type metadata: native uint" or - "Type metadata: float32" or - "Type metadata: float64" or - "Type metadata: string" or - "Type metadata: object"; -} diff --git a/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromNativeAotObject.cs b/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromNativeAotObject.cs new file mode 100644 index 00000000000..d5fe9a2294d --- /dev/null +++ b/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromNativeAotObject.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Android.Build.Tasks; +using Microsoft.Build.Framework; +using Xamarin.Android.Tools; +using Properties = Xamarin.Android.Tasks.Properties; + +namespace Microsoft.Android.Tasks; + +public class ExtractTypeMapKeysFromNativeAotObject : AsyncTask +{ + const string TypeMapSymbol = "__external_type_map__"; + + public override string TaskPrefix => "ETMKNAO"; + + [Required] + public ITaskItem [] NativeObjectFiles { get; set; } = []; + + [Required] + public string LlvmReadObjPath { get; set; } = ""; + + [Required] + public string OutputFile { get; set; } = ""; + + public override async Task RunTaskAsync () + { + string currentFile = nameof (NativeObjectFiles); + try { + if (NativeObjectFiles.Length == 0) { + throw new BadImageFormatException ("No NativeAOT object files were supplied."); + } + currentFile = LlvmReadObjPath; + if (!Path.IsPathFullyQualified (LlvmReadObjPath) || !File.Exists (LlvmReadObjPath)) { + throw new FileNotFoundException ("An existing full path to llvm-readobj is required.", LlvmReadObjPath); + } + + var keys = new SortedSet (StringComparer.Ordinal); + foreach (var file in NativeObjectFiles) { + CancellationToken.ThrowIfCancellationRequested (); + currentFile = file.ItemSpec; + using var stream = File.OpenRead (currentFile); + string metadata = await ReadObjectMetadataAsync (currentFile).ConfigureAwait (false); + using var document = JsonDocument.Parse (metadata); + ReadKeys (stream, document.RootElement, keys); + } + + CancellationToken.ThrowIfCancellationRequested (); + currentFile = OutputFile; + string? directory = Path.GetDirectoryName (OutputFile); + if (!string.IsNullOrEmpty (directory)) { + Directory.CreateDirectory (directory); + } + using var writer = new StreamWriter (OutputFile, append: false, new UTF8Encoding (false, true)) { NewLine = "\n" }; + foreach (string key in keys) { + writer.WriteLine (key); + } + } catch (Exception e) when (e is IOException || e is UnauthorizedAccessException || e is BadImageFormatException || + e is JsonException || e is InvalidOperationException || e is FormatException || e is ArgumentException || e is Win32Exception) { + LogCodedError ("XA4327", Properties.Resources.XA4327, currentFile, e.Message); + } + } + + protected virtual async Task ReadObjectMetadataAsync (string objectFile) + { + using var stdout = new StringWriter (CultureInfo.InvariantCulture); + using var stderr = new StringWriter (CultureInfo.InvariantCulture); + var startInfo = ProcessUtils.CreateProcessStartInfo (LlvmReadObjPath, + "--elf-output-style=JSON", "--file-headers", "--sections", "--symbols", Path.GetFullPath (objectFile)); + int exitCode = await ProcessUtils.StartProcess (startInfo, stdout, stderr, CancellationToken).ConfigureAwait (false); + if (exitCode != 0) { + throw new InvalidOperationException ($"llvm-readobj exited with code {exitCode}: {stderr}"); + } + return stdout.ToString (); + } + + static void ReadKeys (FileStream stream, JsonElement root, ISet keys) + { + if (root.ValueKind != JsonValueKind.Array || root.GetArrayLength () != 1) { + throw new BadImageFormatException ("Expected llvm-readobj metadata for one native object."); + } + var file = root [0]; + var summary = Property (file, "FileSummary"); + string format = Text (summary, "Format"); + string architecture = Text (summary, "Arch"); + var header = Property (file, "ElfHeader"); + if (!format.StartsWith ("elf", StringComparison.Ordinal) || + (architecture != "aarch64" && architecture != "arm" && architecture != "x86_64" && architecture != "i386") || + Text (header, "Type") != "Relocatable (0x1)" || + Number (Property (Property (header, "Ident"), "DataEncoding"), "Value") != 1) { + throw new BadImageFormatException ("Expected a little-endian ARM, ARM64, x86, or x64 relocatable ELF object."); + } + + var sections = new Dictionary (); + var sectionList = Property (file, "Sections"); + foreach (var item in sectionList.EnumerateArray ()) { + var section = Property (item, "Section"); + if (!sections.TryAdd (Number (section, "Index"), section)) { + throw new BadImageFormatException ("Duplicate ELF section index in llvm-readobj output."); + } + } + + bool found = false; + foreach (var item in Property (file, "Symbols").EnumerateArray ()) { + var symbol = Property (item, "Symbol"); + string name = Text (Property (symbol, "Name"), "Name"); + if (!name.EndsWith (TypeMapSymbol, StringComparison.Ordinal)) { + continue; + } + found = true; + long sectionIndex = Number (Property (symbol, "Section"), "Value"); + if (!sections.TryGetValue (sectionIndex, out var section) || + Text (Property (section, "Type"), "Name") != "SHT_PROGBITS") { + throw new BadImageFormatException ($"NativeAOT type map symbol '{name}' has no file-backed data section."); + } + long sectionOffset = Number (section, "Offset"); + long sectionSize = Number (section, "Size"); + long value = Number (symbol, "Value"); + long size = Number (symbol, "Size"); + if (sectionOffset > stream.Length || sectionSize > stream.Length - sectionOffset || + value > sectionSize || size > sectionSize - value || size == 0 || size > int.MaxValue) { + throw new BadImageFormatException ($"NativeAOT type map symbol '{name}' has an invalid file range."); + } + + // LLVM supplies the ELF layout. Only the symbol's relocation-free NativeFormat + // payload is interpreted here; unrelated strings elsewhere in the object are ignored. + stream.Position = sectionOffset + value; + var data = new byte [(int) size]; + stream.ReadExactly (data); + new NativeAotTypeMapReader (data).ReadKeys (keys); + } + if (!found) { + throw new BadImageFormatException ("No NativeAOT external type map symbol was found."); + } + } + + static JsonElement Property (JsonElement element, string name) + { + if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty (name, out var value)) { + throw new BadImageFormatException ($"llvm-readobj output is missing '{name}'."); + } + return value; + } + + static string Text (JsonElement element, string name) + { + var value = Property (element, name); + if (value.ValueKind != JsonValueKind.String || value.GetString () is not string text) { + throw new BadImageFormatException ($"llvm-readobj property '{name}' is not a string."); + } + return text; + } + + static long Number (JsonElement element, string name) + { + var value = Property (element, name); + if (value.ValueKind != JsonValueKind.Number || !value.TryGetInt64 (out long number) || number < 0) { + throw new BadImageFormatException ($"llvm-readobj property '{name}' is not a nonnegative integer."); + } + return number; + } +} diff --git a/src/Microsoft.Android.Build.Tasks/Utilities/NativeAotTypeMapReader.cs b/src/Microsoft.Android.Build.Tasks/Utilities/NativeAotTypeMapReader.cs new file mode 100644 index 00000000000..23e80c5914b --- /dev/null +++ b/src/Microsoft.Android.Build.Tasks/Utilities/NativeAotTypeMapReader.cs @@ -0,0 +1,149 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.Android.Tasks; + +// ExternalTypeMapObjectNode emits NativeFormat group and key hashtables, with blob-relative +// offsets and UTF-8 strings. Type references are fixup indices, not native pointers. +sealed class NativeAotTypeMapReader (byte [] data) +{ + static readonly UTF8Encoding Utf8 = new UTF8Encoding (false, true); + + public void ReadKeys (ISet keys) + { + var groups = new HashSet (); + var entries = new HashSet (); + foreach (int groupOffset in ReadHashtable (0)) { + if (!groups.Add (groupOffset)) { + continue; + } + int offset = groupOffset; + ReadUnsigned (ref offset); // Group type's common-fixup index. + if (ReadUnsigned (ref offset) != 1) { + throw new BadImageFormatException ("The NativeAOT object contains an invalid external type map."); + } + + foreach (int entryOffset in ReadHashtable (offset)) { + if (!entries.Add (entryOffset)) { + continue; + } + int entry = entryOffset; + uint length = ReadUnsigned (ref entry); + RequireRange (entry, length); + string key = Utf8.GetString (data, entry, (int) length); + entry += (int) length; + ReadUnsigned (ref entry); // Target type's common-fixup index. + + key = TypeMapKey.NormalizeAliasKey (key); + if (!TypeMapClassName.TryGetClassName (key, out string? className)) { + throw new BadImageFormatException ($"Invalid NativeAOT type map class name '{key}'."); + } + if (className != null) { + keys.Add (className); + } + } + } + } + + IEnumerable ReadHashtable (int offset) + { + byte header = ReadByte (ref offset); + int indexSize = header & 3; + int bucketShift = header >> 2; + if (indexSize == 3 || bucketShift >= 31) { + throw new BadImageFormatException ("Invalid NativeFormat hashtable header."); + } + + int bucketCount = 1 << bucketShift; + int indexWidth = 1 << indexSize; + long indexBytes = ((long) bucketCount + 1) * indexWidth; + RequireRange (offset, indexBytes); + int baseOffset = offset; + uint start = ReadIndex (baseOffset, indexWidth); + if (start < indexBytes) { + throw new BadImageFormatException ("NativeFormat bucket entries overlap the index table."); + } + + for (int bucket = 0; bucket < bucketCount; bucket++) { + uint end = ReadIndex (baseOffset + (bucket + 1) * indexWidth, indexWidth); + if (end < start) { + throw new BadImageFormatException ("NativeFormat bucket offsets are not ordered."); + } + RequireRange (baseOffset, end); + int position = baseOffset + (int) start; + int limit = baseOffset + (int) end; + while (position < limit) { + ReadByte (ref position); // Low hash byte; enumeration does not need the hash. + int relativeTo = position; + int relativeOffset = ReadSigned (ref position); + if (position > limit) { + throw new BadImageFormatException ("A NativeFormat entry extends past its bucket."); + } + long target = (long) relativeTo + relativeOffset; + RequireRange (target, 1); + yield return (int) target; + } + start = end; + } + } + + uint ReadIndex (int offset, int width) + { + RequireRange (offset, width); + return width switch { + 1 => data [offset], + 2 => BinaryPrimitives.ReadUInt16LittleEndian (data.AsSpan (offset, 2)), + _ => BinaryPrimitives.ReadUInt32LittleEndian (data.AsSpan (offset, 4)), + }; + } + + uint ReadUnsigned (ref int offset) => ReadInteger (ref offset, out _); + + int ReadSigned (ref int offset) + { + uint value = ReadInteger (ref offset, out int bits); + int shift = 32 - bits; + return unchecked ((int) (value << shift)) >> shift; + } + + uint ReadInteger (ref int offset, out int bits) + { + byte first = ReadByte (ref offset); + int extraBytes = 0; + while ((first & (1 << extraBytes)) != 0 && extraBytes < 5) { + extraBytes++; + } + if (extraBytes == 5) { + throw new BadImageFormatException ("Invalid NativeFormat integer encoding."); + } + RequireRange (offset, extraBytes); + if (extraBytes == 4) { + uint value = BinaryPrimitives.ReadUInt32LittleEndian (data.AsSpan (offset, 4)); + offset += 4; + bits = 32; + return value; + } + + uint encoded = first; + for (int i = 1; i <= extraBytes; i++) { + encoded |= (uint) data [offset++] << (8 * i); + } + bits = 7 * (extraBytes + 1); + return encoded >> (extraBytes + 1); + } + + byte ReadByte (ref int offset) + { + RequireRange (offset, 1); + return data [offset++]; + } + + void RequireRange (long offset, long length) + { + if (offset < 0 || length < 0 || offset > data.Length || length > data.Length - offset) { + throw new BadImageFormatException ("A NativeFormat value extends outside the external type map blob."); + } + } +} diff --git a/src/Microsoft.Android.Build.Tasks/Utilities/TypeMapKey.cs b/src/Microsoft.Android.Build.Tasks/Utilities/TypeMapKey.cs new file mode 100644 index 00000000000..843d75cd9a3 --- /dev/null +++ b/src/Microsoft.Android.Build.Tasks/Utilities/TypeMapKey.cs @@ -0,0 +1,18 @@ +namespace Microsoft.Android.Tasks; + +static class TypeMapKey +{ + public static string NormalizeAliasKey (string key) + { + int start = key.LastIndexOf ('['); + if (start <= 0 || start == key.Length - 2 || key [key.Length - 1] != ']') { + return key; + } + for (int i = start + 1; i < key.Length - 1; i++) { + if (key [i] < '0' || key [i] > '9') { + return key; + } + } + return key.Substring (0, start); + } +} diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromAssembliesTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromAssembliesTests.cs index a3acb0cbe13..73d262b8e23 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromAssembliesTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromAssembliesTests.cs @@ -89,6 +89,21 @@ public void NormalizesOnlyFinalNonemptyAsciiDecimalAlias (string key, string exp Assert.Equal (expected + "\n", File.ReadAllText (task.OutputFile)); } + [Theory] + [InlineData ("[Ljava/lang/Object;", "java/lang/Object\n")] + [InlineData ("[[Ltest/Outer$Inner;", "test/Outer$Inner\n")] + [InlineData ("[Ltest/Outer$Inner;[000]", "test/Outer$Inner\n")] + [InlineData ("[B", "")] + [InlineData ("[[Z", "")] + [InlineData ("[I[0]", "")] + public void ArrayEntriesKeepOnlyTheirObjectElementClass (string key, string expected) + { + var (task, engine) = CreateTask (Emit ("Map", Entry (key))); + Assert.True (task.Execute ()); + Assert.Empty (engine.Errors); + Assert.Equal (new UTF8Encoding (false).GetBytes (expected), File.ReadAllBytes (task.OutputFile)); + } + [Fact] public void EmptyStubsAndOrdinaryManagedAssembliesContributeNoKeys () { @@ -180,6 +195,10 @@ public void BadRequiredAssemblyFailsWithoutWritingOutput (string kind) [InlineData ("/test")] [InlineData ("test//Invalid")] [InlineData ("test/Invalid\u200bName")] + [InlineData ("[L;")] + [InlineData ("[V")] + [InlineData ("[Ltest/A")] + [InlineData ("[[")] public void InvalidKeyFails (string key) { var (task, engine) = CreateTask (Emit ("Map", Entry (key))); diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromDgmlTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromDgmlTests.cs deleted file mode 100644 index e4f58467b6c..00000000000 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromDgmlTests.cs +++ /dev/null @@ -1,441 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using Microsoft.Android.Tasks; -using Microsoft.Build.Framework; -using Microsoft.Build.Utilities; -using Xunit; - -namespace Microsoft.Android.Sdk.TrimmableTypeMap.Tests; - -public class ExtractTypeMapKeysFromDgmlTests : IDisposable -{ - readonly string directory = Path.Combine (Path.GetTempPath (), nameof (ExtractTypeMapKeysFromDgmlTests), Guid.NewGuid ().ToString ("N")); - - const string RetainedGraph = """ - - - - - - """; - - const string EmptyGraph = """ - - - - """; - - [Fact] - public void TaskLivesInModernBuildTasksAssembly () - { - Assert.Equal ("Microsoft.Android.Tasks.ExtractTypeMapKeysFromDgml", typeof (ExtractTypeMapKeysFromDgml).FullName); - Assert.Equal ("Microsoft.Android.Build.Tasks", typeof (ExtractTypeMapKeysFromDgml).Assembly.GetName ().Name); - } - - [Fact] - public void Execute_UnionsGraphsAndMatchesOnlyQualifiedMetadata () - { - var (task, errors) = CreateTask (""" - UnnamedProject.MainActivity, UnnamedProject;crc64a1.MainActivity - Android.App.Activity, Mono.Android;android.app.Activity - Duplicate.Type, My.Assembly;my.app.Duplicate - AndroidX.Activity.Result.Contract.ActivityResultContracts+TakePicture, Xamarin.AndroidX.Activity;androidx.activity.result.contract.ActivityResultContracts$TakePicture - Duplicate.Type;wrong.Unqualified - Duplicate.Type, Other.Assembly;wrong.Assembly - Removed.Type, My.Assembly;removed.Type - Links.Type, My.Assembly;wrong.Links - Nested.Type, My.Assembly;wrong.NestedNode - Foreign.Type, My.Assembly;wrong.ForeignNamespace - Second.Type, My.Assembly;second.Type - Second.Type, My.Assembly;second.Type - Second.Type, My.Assembly;second/Type - """, true, """ - - - - - - - - - - - - - - - - - - - - - """, """ - - - - - - - - """); - - Assert.True (task.Execute ()); - Assert.Empty (errors); - const string expected = "android/app/Activity\n" + - "androidx/activity/result/contract/ActivityResultContracts$TakePicture\n" + - "crc64a1/MainActivity\n" + - "my/app/Duplicate\n" + - "second/Type\n"; - AssertOutput (task, expected); - - Array.Reverse (task.NativeAotDgmlFiles); - task.NativeAotDgmlFiles = task.NativeAotDgmlFiles.Concat (task.NativeAotDgmlFiles).ToArray (); - var mapLines = File.ReadAllLines (task.AcwMapFile); - Array.Reverse (mapLines); - File.WriteAllLines (task.AcwMapFile, mapLines); - Assert.True (task.Execute (), "Input ordering and duplicated graphs must not change the retained set."); - AssertOutput (task, expected); - } - - [Theory] - [InlineData ("absent")] - [InlineData ("missing")] - [InlineData ("malformed")] - public void Execute_KeepsAllMappedClassesWithoutReadingGraphsWhenTrimmingDisabled (string graph) - { - var (task, errors) = CreateTask (""" - Example.Type, App;example.Type - Example.Type;example.Type - example.Type;example.Type - Other.Type;other.Type$Nested - """, false); - if (graph == "missing") { - task.NativeAotDgmlFiles = [new TaskItem (Path.Combine (directory, "missing.dgml.xml"))]; - } else if (graph == "malformed") { - var path = Path.ChangeExtension (task.AcwMapFile, ".dgml.xml"); - File.WriteAllText (path, "not XML"); - task.NativeAotDgmlFiles = [new TaskItem (path)]; - } - - Assert.True (task.Execute ()); - Assert.Empty (errors); - AssertOutput (task, "example/Type\nother/Type$Nested\n"); - } - - [Fact] - public void Execute_PreservesUnicodeAndWritesExactOrdinalUtf8Bytes () - { - const string map = "Example.Type, App;com.\u00e9xample.Peer\u0394$Nested\n" + - "Example.Type, App;com.example.\U00010400Peer\n" + - "Example.Type, App;com.example.\u2160Peer\n" + - "Example.Type, App;com.example.A\u0301\n" + - "Example.Type, App;com.example.a\n" + - "Example.Type, App;com.example.Z\n"; - var (task, errors) = CreateTask (map, true, RetainedGraph); - File.WriteAllText (task.AcwMapFile, map, new UTF8Encoding (true)); - - Assert.True (task.Execute ()); - Assert.Empty (errors); - AssertOutput (task, "com/example/A\u0301\ncom/example/Z\ncom/example/a\n" + - "com/example/\u2160Peer\ncom/example/\U00010400Peer\ncom/\u00e9xample/Peer\u0394$Nested\n"); - } - - [Theory] - [InlineData (true)] - [InlineData (false)] - public void Execute_EmptyMapProducesZeroByteFile (bool trim) - { - var (task, errors) = CreateTask ("", trim, RetainedGraph); - - Assert.True (task.Execute ()); - Assert.Empty (errors); - AssertOutput (task, ""); - } - - [Theory] - [InlineData (EmptyGraph)] - [InlineData ("")] - [InlineData ("")] - public void Execute_ValidGraphWithNoRetainedTypesProducesZeroByteFile (string graph) - { - var (task, errors) = CreateTask ("Example.Type, App;example.Type", true, graph); - - Assert.True (task.Execute ()); - Assert.Empty (errors); - AssertOutput (task, ""); - } - - [Fact] - public void Execute_AcceptsGraphWithoutNamespace () - { - var (task, errors) = CreateTask ("Example.Type, App;example.Type", true, - ""); - - Assert.True (task.Execute ()); - Assert.Empty (errors); - AssertOutput (task, "example/Type\n"); - } - - [Fact] - public void Execute_EmptyNodesDoNotIncludeNodesFromLinks () - { - var (task, errors) = CreateTask ("Example.Type, App;example.Type", true, """ - - - - - - - """); - - Assert.True (task.Execute ()); - Assert.Empty (errors); - AssertOutput (task, ""); - } - - [Fact] - public void Execute_RewritesOutputEvenWhenUnchangedAndTruncatesAnEmptySet () - { - var (task, errors) = CreateTask ("Example.Type, App;example.Type", true, RetainedGraph); - Assert.True (task.Execute ()); - AssertOutput (task, "example/Type\n"); - - var oldTime = new DateTime (2000, 1, 1, 0, 0, 0, DateTimeKind.Utc); - File.SetLastWriteTimeUtc (task.OutputFile, oldTime); - Assert.True (task.Execute ()); - Assert.True (File.GetLastWriteTimeUtc (task.OutputFile) > oldTime, "Output is an MSBuild incremental sentinel."); - AssertOutput (task, "example/Type\n"); - - File.WriteAllText (task.NativeAotDgmlFiles [0].ItemSpec, EmptyGraph); - Assert.True (task.Execute ()); - Assert.Empty (errors); - AssertOutput (task, ""); - } - - [Theory] - [InlineData ("map", "XA4320")] - [InlineData ("graphs", "XA4319")] - [InlineData ("first-graph", "XA4321")] - [InlineData ("second-graph", "XA4321")] - public void Execute_ReportsMissingRequiredInput (string missing, string expectedCode) - { - var (task, errors) = CreateTask ("Example.Type, App;example.Type", true, RetainedGraph, RetainedGraph); - switch (missing) { - case "map": - File.Delete (task.AcwMapFile); - break; - case "graphs": - task.NativeAotDgmlFiles = []; - break; - case "first-graph": - File.Delete (task.NativeAotDgmlFiles [0].ItemSpec); - break; - case "second-graph": - File.Delete (task.NativeAotDgmlFiles [1].ItemSpec); - break; - } - - Assert.False (task.Execute ()); - Assert.Equal (expectedCode, Assert.Single (errors).Code); - Assert.False (File.Exists (task.OutputFile)); - } - - [Fact] - public void Execute_RequiresMapWhenTrimmingDisabled () - { - var (task, errors) = CreateTask ("", false); - File.Delete (task.AcwMapFile); - - Assert.False (task.Execute ()); - Assert.Equal ("XA4320", Assert.Single (errors).Code); - Assert.False (File.Exists (task.OutputFile)); - } - - public static IEnumerable InvalidGraphs () - { - yield return [""]; - yield return ["not XML"]; - yield return [""]; - yield return [EmptyGraph + "<"]; - yield return [""]; - yield return [""]; - yield return [""" - ]> - - """]; - yield return [""" - - - """]; - } - - [Theory] - [MemberData (nameof (InvalidGraphs))] - public void Execute_RejectsMalformedOrUnsupportedGraph (string graph) - { - var (task, errors) = CreateTask ("Example.Type, App;example.Type", true, RetainedGraph, graph); - - Assert.False (task.Execute ()); - Assert.Equal ("XA4327", Assert.Single (errors).Code); - Assert.Contains (task.NativeAotDgmlFiles [1].ItemSpec, errors [0].Message); - Assert.False (File.Exists (task.OutputFile), "A valid first graph must not result in partial output."); - } - - [Theory] - [InlineData ("void")] - [InlineData ("bool")] - [InlineData ("char")] - [InlineData ("int8")] - [InlineData ("uint8")] - [InlineData ("int16")] - [InlineData ("uint16")] - [InlineData ("int32")] - [InlineData ("uint32")] - [InlineData ("int64")] - [InlineData ("uint64")] - [InlineData ("native int")] - [InlineData ("native uint")] - [InlineData ("float32")] - [InlineData ("float64")] - [InlineData ("string")] - [InlineData ("object")] - public void Execute_IgnoresIntrinsicTypeMetadata (string type) - { - var (task, errors) = CreateTask ("Example.Type, App;example.Type", true, - $""" - - - - - """); - Assert.True (task.Execute ()); - Assert.Empty (errors); - Assert.Equal ("example/Type\n", File.ReadAllText (task.OutputFile)); - } - - [Theory] - [InlineData ("Type metadata: [App")] - [InlineData ("Type metadata: []Example.Type")] - [InlineData ("Type metadata: [App]")] - [InlineData ("Type metadata: [ ]Example.Type")] - [InlineData ("Type metadata: [App] ")] - [InlineData ("Type metadata: Example.Type")] - public void Execute_RejectsMalformedTypeMetadata (string label) - { - var (task, errors) = CreateTask ("Example.Type, App;example.Type", true, - $""); - - Assert.False (task.Execute ()); - Assert.Equal ("XA4327", Assert.Single (errors).Code); - Assert.False (File.Exists (task.OutputFile)); - } - - [Fact] - public void Execute_ProhibitsLocalExternalEntities () - { - var (task, errors) = CreateTask ("Example.Type, App;example.Type", true, RetainedGraph); - var externalFile = Path.ChangeExtension (task.AcwMapFile, ".external.xml"); - const string externalContent = "external-content-must-not-be-read"; - File.WriteAllText (externalFile, externalContent); - File.WriteAllText (task.NativeAotDgmlFiles [0].ItemSpec, $""" - ]> - &external; - """); - - Assert.False (task.Execute ()); - Assert.Equal ("XA4327", Assert.Single (errors).Code); - Assert.DoesNotContain (externalContent, errors [0].Message); - Assert.False (File.Exists (task.OutputFile)); - } - - [Theory] - [InlineData ("missing separator")] - [InlineData (";example.Type")] - [InlineData (" ;example.Type")] - [InlineData ("Example.Type, App;")] - [InlineData ("Example.Type, App;example.Type;extra")] - [InlineData ("Example.Type, App;example.Type[1]")] - [InlineData ("Example.Type, App;example.Type[]")] - [InlineData ("Example.Type, App;.example.Type")] - [InlineData ("Example.Type, App;example..Type")] - [InlineData ("Example.Type, App;example/Type/")] - [InlineData ("Example.Type, App;example. Type")] - [InlineData ("Example.Type, App;example.Type\u200b")] - [InlineData ("Example.Type, App;example.*")] - [InlineData ("Example.Type, App;-keep class example.Type")] - public void Execute_RejectsMalformedMapEntries (string map) - { - var (task, errors) = CreateTask (map, true, EmptyGraph); - - Assert.False (task.Execute (), "Invalid map data must fail even if the graph retains no types."); - Assert.Equal ("XA4327", Assert.Single (errors).Code); - Assert.Contains (task.AcwMapFile, errors [0].Message); - Assert.False (File.Exists (task.OutputFile)); - } - - [Fact] - public void Execute_RejectsNumericAliasesWhenTrimmingDisabled () - { - var (task, errors) = CreateTask ("Example.Type;example.Type[1]", false); - - Assert.False (task.Execute ()); - Assert.Equal ("XA4327", Assert.Single (errors).Code); - Assert.False (File.Exists (task.OutputFile)); - } - - [Theory] - [InlineData (false)] - [InlineData (true)] - public void Execute_RejectsInvalidMapEncoding (bool byteOrderMark) - { - var (task, errors) = CreateTask ("", false); - File.WriteAllBytes (task.AcwMapFile, byteOrderMark ? [0xef, 0xbb, 0xbf, 0xc3, 0x28] : [0xc3, 0x28]); - - Assert.False (task.Execute ()); - Assert.Equal ("XA4327", Assert.Single (errors).Code); - Assert.False (File.Exists (task.OutputFile)); - } - - (ExtractTypeMapKeysFromDgml Task, List Errors) CreateTask (string map, bool trim, params string [] graphs) - { - Directory.CreateDirectory (directory); - var acwMapFile = Path.Combine (directory, "acw-map.txt"); - File.WriteAllText (acwMapFile, map); - var graphItems = new List (); - for (int i = 0; i < graphs.Length; i++) { - var path = Path.Combine (directory, $"{i}.dgml.xml"); - File.WriteAllText (path, graphs [i]); - graphItems.Add (new TaskItem (path)); - } - var outputFile = Path.Combine (directory, "keys", "retained-java-types.txt"); - if (File.Exists (outputFile)) { - File.Delete (outputFile); - } - var engine = new TypeMapTaskBuildEngine (); - return (new ExtractTypeMapKeysFromDgml { - BuildEngine = engine, - NativeAotDgmlFiles = graphItems.ToArray (), - AcwMapFile = acwMapFile, - TrimJavaCallableWrappers = trim, - OutputFile = outputFile, - }, engine.Errors); - } - - static void AssertOutput (ExtractTypeMapKeysFromDgml task, string expected) - { - Assert.True (File.Exists (task.OutputFile), "Successful extraction must create a real output file."); - Assert.Equal (new UTF8Encoding (false, true).GetBytes (expected), File.ReadAllBytes (task.OutputFile)); - } - - public void Dispose () - { - if (Directory.Exists (directory)) { - Directory.Delete (directory, recursive: true); - } - } -} diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromNativeAotObjectTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromNativeAotObjectTests.cs new file mode 100644 index 00000000000..b9c976ffca5 --- /dev/null +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromNativeAotObjectTests.cs @@ -0,0 +1,579 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Microsoft.Android.Tasks; +using Xunit; +using TaskItem = Microsoft.Build.Utilities.TaskItem; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap.Tests; + +public class ExtractTypeMapKeysFromNativeAotObjectTests : IDisposable +{ + const int SectionOffset = 128; + const int SymbolOffset = 17; + readonly string directory = Path.Combine (AppContext.BaseDirectory, nameof (ExtractTypeMapKeysFromNativeAotObjectTests), Guid.NewGuid ().ToString ("N")); + readonly Dictionary metadata = new (StringComparer.Ordinal); + + public ExtractTypeMapKeysFromNativeAotObjectTests () + { + Directory.CreateDirectory (directory); + File.WriteAllBytes (Path.Combine (directory, "llvm-readobj-placeholder"), []); + } + + public void Dispose () => Directory.Delete (directory, recursive: true); + + [Fact] + public void TaskLivesInModernBuildTasksAssembly () + { + Assert.Equal ("Microsoft.Android.Tasks.ExtractTypeMapKeysFromNativeAotObject", typeof (ExtractTypeMapKeysFromNativeAotObject).FullName); + Assert.Equal ("Microsoft.Android.Build.Tasks", typeof (ExtractTypeMapKeysFromNativeAotObject).Assembly.GetName ().Name); + } + + [Fact] + public void CapturedIlcArm64BlobContainsOnlyRetainedCanonicalKeys () + { + // Microsoft.DotNet.ILCompiler 11.0.0-rc.2.26461.115, Android ARM64, optimized with --scanreflection. + byte [] blob = Convert.FromBase64String ("AAIFXbUBGnNhbXBsZS9BbHdheXMEFnNhbXBsZS9MaXZlAkJzYW1wbGUvTGl2ZVsxMjM0NTY3ODkwMTIzNDU2Nzg5MF0CNGNvbS/DqXhhbXBsZS9QZWVyzpQkTmVzdGVkBBxzYW1wbGUvTGl2ZVswXQIAAgACD06t/lxt/qPIpx3+zIY="); + var (task, engine) = CreateTask (WriteObject ("captured-ilc-arm64", blob)); + + Assert.True (task.Execute ()); + Assert.Empty (engine.Errors); + byte [] expected = new UTF8Encoding (false).GetBytes ("com/\u00e9xample/Peer\u0394$Nested\nsample/Always\nsample/Live\n"); + Assert.Equal (expected, File.ReadAllBytes (task.OutputFile)); + } + + [Fact] + public void ExtractsOnlySymbolPayloadAndUnionsEveryGroupAndObject () + { + string arm64 = WriteObject ("arm64", NativeAotObjectTestFixture.CreateGroups ( + ["test/Zebra", "test/Outer$Inner", "test/Alias[0]", "test/\u00e9clair"], + ["test/FromOtherGroup", "test/Alias[1]", "test/\U00010400Peer"])); + string x64 = WriteObject ("x64", NativeAotObjectTestFixture.CreateGroups ( + ["test/Alpha", "test/Alias", "test/Outer$Inner"], + ["test/A\u0301", "test/a", "test/\u2160Peer"])); + Summary (x64) ["Format"] = "elf64-x86-64"; + Summary (x64) ["Arch"] = "x86_64"; + Symbol (arm64) ["Name"] = new JsonObject { ["Name"] = "Compilation_123___external_type_map__", ["Value"] = 42 }; + var (task, engine) = CreateTask (arm64, x64, arm64); + Directory.CreateDirectory (Path.GetDirectoryName (task.OutputFile) ?? throw new InvalidOperationException ()); + File.WriteAllText (task.OutputFile, "stale/sentinel\n"); + + Assert.True (task.Execute ()); + Assert.Empty (engine.Errors); + byte [] expected = new UTF8Encoding (false).GetBytes ( + "test/Alias\ntest/Alpha\ntest/A\u0301\ntest/FromOtherGroup\ntest/Outer$Inner\ntest/Zebra\ntest/a\ntest/\u00e9clair\ntest/\u2160Peer\ntest/\U00010400Peer\n"); + Assert.Equal (expected, File.ReadAllBytes (task.OutputFile)); + + File.SetLastWriteTimeUtc (task.OutputFile, new DateTime (2000, 1, 1)); + var (reordered, reorderedEngine) = CreateTask (x64, arm64); + Assert.True (reordered.Execute ()); + Assert.Empty (reorderedEngine.Errors); + Assert.Equal (expected, File.ReadAllBytes (reordered.OutputFile)); + Assert.True (File.GetLastWriteTimeUtc (reordered.OutputFile).Year > 2000); + } + + [Theory] + [InlineData ("elf32-littlearm", "arm", "32bit")] + [InlineData ("elf64-littleaarch64", "aarch64", "64bit")] + [InlineData ("elf64-x86-64", "x86_64", "64bit")] + [InlineData ("elf32-i386", "i386", "32bit")] + public void AcceptsSupportedRelocatableElfArchitectures (string format, string arch, string addressSize) + { + string path = WriteObject ("map", NativeAotObjectTestFixture.CreateBlob ("test/Live")); + Summary (path) ["Format"] = format; + Summary (path) ["Arch"] = arch; + Summary (path) ["AddressSize"] = addressSize; + var (task, engine) = CreateTask (path); + + Assert.True (task.Execute ()); + Assert.Empty (engine.Errors); + Assert.Equal ("test/Live\n", File.ReadAllText (task.OutputFile)); + } + + [Theory] + [InlineData ("test/A[123]", "test/A")] + [InlineData ("test/A[000]", "test/A")] + [InlineData ("test/A[9999999999999999999999999999999]", "test/A")] + [InlineData ("test/Outer$Inner[2]", "test/Outer$Inner")] + [InlineData ("test/\u2160[1]", "test/\u2160")] + public void RemovesOneTerminalNonemptyAsciiDecimalAlias (string key, string expected) + { + var (task, engine) = CreateTask (WriteObject ("map", NativeAotObjectTestFixture.CreateBlob (key))); + + Assert.True (task.Execute ()); + Assert.Empty (engine.Errors); + Assert.Equal (new UTF8Encoding (false).GetBytes (expected + "\n"), File.ReadAllBytes (task.OutputFile)); + } + + [Fact] + public void ObjectArraysContributeOnlyCanonicalElementClasses () + { + byte [] blob = NativeAotObjectTestFixture.CreateBlob ( + "[Ljava/lang/Object;", "[[Ltest/Outer$Inner;", "[Ltest/Outer$Inner;[000]", + "test/Outer$Inner", "[I[0]"); + var (task, engine) = CreateTask (WriteObject ("arrays", blob)); + + Assert.True (task.Execute ()); + Assert.Empty (engine.Errors); + Assert.Equal ("java/lang/Object\ntest/Outer$Inner\n", File.ReadAllText (task.OutputFile)); + } + + [Fact] + public void PrimitiveArraysContributeNoClasses () + { + byte [] blob = NativeAotObjectTestFixture.CreateBlob ("[Z", "[B", "[C", "[S", "[I", "[J", "[F", "[D", "[[I", "[[[D"); + var (task, engine) = CreateTask (WriteObject ("primitive-arrays", blob)); + + Assert.True (task.Execute ()); + Assert.Empty (engine.Errors); + Assert.True (File.Exists (task.OutputFile)); + Assert.Empty (File.ReadAllBytes (task.OutputFile)); + } + + [Theory] + [InlineData (true)] + [InlineData (false)] + public void EmptyOuterOrInnerTableOverwritesWithZeroByteFile (bool emptyOuter) + { + byte [] blob = emptyOuter ? NativeAotObjectTestFixture.CreateGroups () : NativeAotObjectTestFixture.CreateBlob (); + string path = WriteObject ("empty", blob); + var (task, engine) = CreateTask (path); + + Assert.True (task.Execute ()); + Assert.Empty (engine.Errors); + Assert.True (File.Exists (task.OutputFile)); + Assert.Empty (File.ReadAllBytes (task.OutputFile)); + + File.WriteAllText (task.OutputFile, "stale/Key\n"); + for (int run = 0; run < 2; run++) { + File.SetLastWriteTimeUtc (task.OutputFile, new DateTime (2000, 1, 1)); + var (repeat, repeatEngine) = CreateTask (path); + Assert.True (repeat.Execute ()); + Assert.Empty (repeatEngine.Errors); + Assert.Empty (File.ReadAllBytes (repeat.OutputFile)); + Assert.True (File.GetLastWriteTimeUtc (repeat.OutputFile).Year > 2000); + } + } + + [Theory] + [InlineData (1, 0)] + [InlineData (1, 2)] + [InlineData (2, 1)] + [InlineData (4, 3)] + public void ReadsBucketIndexWidthsAndMultipleBuckets (int indexWidth, int bucketShift) + { + byte [] inner = NativeAotObjectTestFixture.CreateTable ( + new [] { "test/Z", "test/A", "test/M" }.Select (key => NativeAotObjectTestFixture.CreateKey (key)).ToArray (), + indexWidth, bucketShift); + byte [] group = NativeAotObjectTestFixture.CreateGroup (inner); + byte [] blob = NativeAotObjectTestFixture.CreateTable ([group, group], indexWidth, bucketShift); + var (task, engine) = CreateTask (WriteObject ("buckets", blob)); + + Assert.True (task.Execute ()); + Assert.Empty (engine.Errors); + Assert.Equal ("test/A\ntest/M\ntest/Z\n", File.ReadAllText (task.OutputFile)); + } + + [Theory] + [InlineData (1, false)] + [InlineData (2, false)] + [InlineData (3, false)] + [InlineData (4, false)] + [InlineData (5, false)] + [InlineData (1, true)] + [InlineData (2, true)] + [InlineData (3, true)] + [InlineData (4, true)] + [InlineData (5, true)] + public void RelativeOffsetsUseTheIntegerAddressAndCanPointBackwards (int relativeWidth, bool backwards) + { + byte [] inner = NativeAotObjectTestFixture.CreateTable ( + [NativeAotObjectTestFixture.CreateKey ("test/Live")], relativeWidth: relativeWidth, backwards: backwards); + byte [] blob = NativeAotObjectTestFixture.CreateTable ( + [NativeAotObjectTestFixture.CreateGroup (inner)], relativeWidth: relativeWidth, backwards: backwards); + var (task, engine) = CreateTask (WriteObject ("relative", blob)); + + Assert.True (task.Execute ()); + Assert.Empty (engine.Errors); + Assert.Equal ("test/Live\n", File.ReadAllText (task.OutputFile)); + } + + [Theory] + [InlineData (0u)] + [InlineData (128u)] + [InlineData (16384u)] + [InlineData (2097152u)] + [InlineData (268435456u)] + [InlineData (uint.MaxValue)] + public void ReadsAllUnsignedCompactIntegerWidths (uint typeIndex) + { + byte [] inner = NativeAotObjectTestFixture.CreateTable ([NativeAotObjectTestFixture.CreateKey ("test/Live", typeIndex)]); + byte [] blob = NativeAotObjectTestFixture.CreateTable ([NativeAotObjectTestFixture.CreateGroup (inner, typeIndex: typeIndex)]); + var (task, engine) = CreateTask (WriteObject ("indices", blob)); + + Assert.True (task.Execute ()); + Assert.Empty (engine.Errors); + Assert.Equal ("test/Live\n", File.ReadAllText (task.OutputFile)); + } + + [Theory] + [InlineData (127)] + [InlineData (128)] + [InlineData (16384)] + [InlineData (2097152)] + public void ReadsMultibyteStringLengthsWithoutTruncatingKeys (int byteLength) + { + string key = "test/" + new string ('A', byteLength - 5); + var (task, engine) = CreateTask (WriteObject ("length", NativeAotObjectTestFixture.CreateBlob (key))); + + Assert.True (task.Execute ()); + Assert.Empty (engine.Errors); + Assert.Equal (new UTF8Encoding (false).GetBytes (key + "\n"), File.ReadAllBytes (task.OutputFile)); + } + + [Fact] + public void MissingObjectInputsFail () + { + var (task, engine) = CreateTask (); + AssertFailure (task, engine); + } + + [Theory] + [InlineData ("missing")] + [InlineData ("directory")] + public void MissingOrUnreadableObjectFailsWithFileContext (string kind) + { + string path = Path.Combine (directory, "invalid.o"); + if (kind == "directory") { + Directory.CreateDirectory (path); + } + var (task, engine) = CreateTask (path); + AssertFailure (task, engine, path); + } + + [Theory] + [InlineData (0)] + [InlineData (128)] + public void TruncatedObjectFailsAgainstActualFileBounds (int length) + { + string path = WriteObject ("truncated", NativeAotObjectTestFixture.CreateBlob ("test/Live")); + using (var stream = File.OpenWrite (path)) { + stream.SetLength (length); + } + var (task, engine) = CreateTask (path); + AssertFailure (task, engine, path); + } + + [Theory] + [InlineData ("")] + [InlineData ("missing-llvm-readobj")] + public void MissingToolFailsWithCodedError (string tool) + { + var engine = new TypeMapTaskBuildEngine (); + var task = new ExtractTypeMapKeysFromNativeAotObject { + BuildEngine = engine, + NativeObjectFiles = [new TaskItem (WriteObject ("map", NativeAotObjectTestFixture.CreateBlob ("test/Live")))], + LlvmReadObjPath = tool.Length == 0 ? "" : Path.Combine (directory, tool), + OutputFile = Path.Combine (directory, "keys.txt"), + }; + AssertFailure (task, engine, task.LlvmReadObjPath); + } + + [Theory] + [InlineData ("")] + [InlineData ("not JSON")] + [InlineData ("{}")] + [InlineData ("[]")] + [InlineData ("[{},{}]")] + [InlineData ("[null]")] + [InlineData ("[{}]")] + public void MalformedToolJsonFailsWithFileContext (string json) + { + string path = WriteObject ("map", NativeAotObjectTestFixture.CreateBlob ("test/Live")); + var (task, engine) = CreateTask (path); + task.MetadataReader = _ => json; + AssertFailure (task, engine, path); + } + + [Theory] + [InlineData ("FileSummary")] + [InlineData ("ElfHeader")] + [InlineData ("Sections")] + [InlineData ("Symbols")] + public void MissingRequiredMetadataFails (string property) + { + string path = WriteObject ("map", NativeAotObjectTestFixture.CreateBlob ("test/Live")); + Document (path).Remove (property); + var (task, engine) = CreateTask (path); + AssertFailure (task, engine, path); + } + + [Theory] + [InlineData ("format")] + [InlineData ("architecture")] + [InlineData ("dynamic")] + [InlineData ("executable")] + [InlineData ("big-endian")] + [InlineData ("missing-encoding")] + [InlineData ("no-symbol")] + [InlineData ("suffix-not-terminal")] + [InlineData ("undefined-symbol")] + [InlineData ("no-sections")] + [InlineData ("nobits-section")] + [InlineData ("duplicate-section")] + [InlineData ("negative-offset")] + [InlineData ("section-past-file")] + [InlineData ("section-overruns-file")] + [InlineData ("symbol-past-section")] + [InlineData ("symbol-overruns-section")] + [InlineData ("empty-symbol")] + [InlineData ("huge-symbol")] + [InlineData ("wrong-number-type")] + public void InvalidObjectMetadataFails (string defect) + { + string path = WriteObject ("map", NativeAotObjectTestFixture.CreateBlob ("test/Live")); + var header = Object (Document (path) ["ElfHeader"]); + var symbols = Array (Document (path) ["Symbols"]); + var sections = Array (Document (path) ["Sections"]); + long fileLength = new FileInfo (path).Length; + switch (defect) { + case "format": Summary (path) ["Format"] = "Mach-O arm64"; break; + case "architecture": Summary (path) ["Arch"] = "riscv64"; break; + case "dynamic": header ["Type"] = "SharedObject (0x3)"; break; + case "executable": header ["Type"] = "Executable (0x2)"; break; + case "big-endian": Object (Object (header ["Ident"]) ["DataEncoding"]) ["Value"] = 2; break; + case "missing-encoding": Object (header ["Ident"]).Remove ("DataEncoding"); break; + case "no-symbol": symbols.RemoveAt (1); break; + case "suffix-not-terminal": Object (Symbol (path) ["Name"]) ["Name"] = "__external_type_map__unrelated"; break; + case "undefined-symbol": Object (Symbol (path) ["Section"]) ["Value"] = 0; break; + case "no-sections": sections.Clear (); break; + case "nobits-section": Object (Section (path) ["Type"]) ["Name"] = "SHT_NOBITS"; break; + case "duplicate-section": sections.Add (sections [1]?.DeepClone ()); break; + case "negative-offset": Section (path) ["Offset"] = -1; break; + case "section-past-file": Section (path) ["Offset"] = fileLength + 1; break; + case "section-overruns-file": Section (path) ["Size"] = fileLength; break; + case "symbol-past-section": Symbol (path) ["Value"] = fileLength; break; + case "symbol-overruns-section": Symbol (path) ["Size"] = fileLength; break; + case "empty-symbol": Symbol (path) ["Size"] = 0; break; + case "huge-symbol": Symbol (path) ["Size"] = long.MaxValue; break; + case "wrong-number-type": Symbol (path) ["Value"] = "17"; break; + default: throw new ArgumentException (defect, nameof (defect)); + } + var (task, engine) = CreateTask (path); + AssertFailure (task, engine, path); + } + + [Theory] + [MemberData (nameof (MalformedTables))] + public void MalformedOuterOrInnerTableFails (string defect, byte [] table) + { + foreach (bool inner in new [] { false, true }) { + byte [] blob = inner ? NativeAotObjectTestFixture.CreateTable ([NativeAotObjectTestFixture.CreateGroup (table)]) : table; + string path = WriteObject (defect + (inner ? "-inner" : "-outer"), blob); + var (task, engine) = CreateTask (path); + AssertFailure (task, engine, path); + } + } + + public static IEnumerable MalformedTables () + { + yield return ["missing-header", new byte [] { }]; + yield return ["invalid-index-width", new byte [] { 3 }]; + yield return ["invalid-bucket-count", new byte [] { 252 }]; + yield return ["truncated-indices", new byte [] { 0, 2 }]; + yield return ["indices-overlap-header", new byte [] { 0, 1, 1 }]; + yield return ["unordered-indices", new byte [] { 4, 3, 2, 3 }]; + yield return ["bucket-outside-blob", new byte [] { 0, 2, 250 }]; + yield return ["truncated-cell", new byte [] { 0, 2, 3, 42 }]; + yield return ["truncated-relative", new byte [] { 0, 2, 4, 42, 1 }]; + yield return ["cell-overruns-bucket", new byte [] { 0, 2, 4, 42, 15, 0, 0, 0, 0 }]; + yield return ["positive-relative-overflow", new byte [] { 0, 2, 8, 42, 15, 255, 255, 255, 127 }]; + yield return ["negative-relative-overflow", new byte [] { 0, 2, 8, 42, 15, 0, 0, 0, 128 }]; + yield return ["invalid-integer-tag", new byte [] { 0, 2, 4, 42, 31 }]; + } + + [Theory] + [InlineData (0u)] + [InlineData (2u)] + [InlineData (uint.MaxValue)] + public void InvalidGroupStateIsNotAnEmptyMap (uint state) + { + byte [] group = NativeAotObjectTestFixture.CreateGroup (NativeAotObjectTestFixture.CreateTable ([]), state); + var (task, engine) = CreateTask (WriteObject ("invalid-state", NativeAotObjectTestFixture.CreateTable ([group]))); + AssertFailure (task, engine); + } + + [Theory] + [InlineData (new byte [] { })] + [InlineData (new byte [] { 0 })] + [InlineData (new byte [] { 0, 2 })] + [InlineData (new byte [] { 31 })] + [InlineData (new byte [] { 0, 31 })] + public void TruncatedOrMalformedGroupTupleFails (byte [] group) + { + var (task, engine) = CreateTask (WriteObject ("invalid-group", NativeAotObjectTestFixture.CreateTable ([group]))); + AssertFailure (task, engine); + } + + [Theory] + [MemberData (nameof (MalformedKeyTuples))] + public void MalformedKeyTupleFails (string defect, byte [] tuple) + { + byte [] inner = NativeAotObjectTestFixture.CreateTable ([tuple]); + byte [] blob = NativeAotObjectTestFixture.CreateTable ([NativeAotObjectTestFixture.CreateGroup (inner)]); + var (task, engine) = CreateTask (WriteObject (defect, blob)); + AssertFailure (task, engine); + } + + public static IEnumerable MalformedKeyTuples () + { + yield return ["overflow-length", NativeAotObjectTestFixture.EncodeUnsigned (uint.MaxValue)]; + yield return ["invalid-length-tag", new byte [] { 31 }]; + yield return ["truncated-two-byte-length", new byte [] { 1 }]; + yield return ["truncated-three-byte-length", new byte [] { 3, 0 }]; + yield return ["truncated-four-byte-length", new byte [] { 7, 0, 0 }]; + yield return ["truncated-five-byte-length", new byte [] { 15, 0, 0, 0 }]; + yield return ["length-overruns-blob", new byte [] { 126, 65, 0 }]; + yield return ["missing-target-index", new byte [] { 2, 65 }]; + yield return ["invalid-target-index", new byte [] { 2, 65, 31 }]; + yield return ["overlong-utf8", new byte [] { 4, 192, 175, 0 }]; + yield return ["surrogate-utf8", new byte [] { 6, 237, 160, 128, 0 }]; + yield return ["incomplete-utf8", new byte [] { 4, 240, 144, 0 }]; + yield return ["invalid-utf8-continuation", new byte [] { 2, 128, 0 }]; + } + + [Theory] + [InlineData ("")] + [InlineData ("test/Invalid\nName")] + [InlineData ("test/Invalid\rName")] + [InlineData ("test/Invalid\0Name")] + [InlineData ("test/A[]")] + [InlineData ("test/A[-1]")] + [InlineData ("test/A[1x]")] + [InlineData ("test/A[1]Extra")] + [InlineData ("test/A[1")] + [InlineData ("test/A[")] + [InlineData ("[0]")] + [InlineData ("test/A[\u0661]")] + [InlineData ("test/A[1][2]")] + [InlineData ("test.Invalid")] + [InlineData ("test/*")] + [InlineData ("test/")] + [InlineData ("/test")] + [InlineData ("test//Invalid")] + [InlineData ("test/Invalid\u200bName")] + [InlineData ("[L;")] + [InlineData ("[V")] + [InlineData ("[Q")] + [InlineData ("[Iextra")] + [InlineData ("[Ljava.lang.Object;")] + [InlineData ("[Ljava/lang/Object")] + [InlineData ("[Ljava/lang/Object;;")] + [InlineData ("[Ltest/A[0];")] + [InlineData ("[[")] + public void InvalidCanonicalKeysOrAliasesFail (string key) + { + var (task, engine) = CreateTask (WriteObject ("invalid-key", NativeAotObjectTestFixture.CreateBlob (key))); + AssertFailure (task, engine); + } + + [Fact] + public void InvalidSecondObjectDoesNotWritePartialOutput () + { + string valid = WriteObject ("valid", NativeAotObjectTestFixture.CreateBlob ("test/Live")); + string invalid = WriteObject ("invalid", [3]); + var (task, engine) = CreateTask (valid, invalid); + AssertFailure (task, engine, invalid); + } + + [Fact] + public void InvalidLaterGroupDoesNotWritePartialOutput () + { + byte [] valid = NativeAotObjectTestFixture.CreateGroup ( + NativeAotObjectTestFixture.CreateTable ([NativeAotObjectTestFixture.CreateKey ("test/Live")])); + byte [] invalid = NativeAotObjectTestFixture.CreateGroup (NativeAotObjectTestFixture.CreateTable ([]), state: 0); + var (task, engine) = CreateTask (WriteObject ("mixed-groups", NativeAotObjectTestFixture.CreateTable ([valid, invalid]))); + AssertFailure (task, engine); + } + + [Fact] + public void MetadataReadFailureIsReportedWithoutPartialOutput () + { + string path = WriteObject ("map", NativeAotObjectTestFixture.CreateBlob ("test/Live")); + var (task, engine) = CreateTask (path); + task.MetadataReader = _ => throw new IOException ("Could not read object metadata."); + AssertFailure (task, engine, path); + } + + [Fact] + public void OutputWriteFailureIsReported () + { + var (task, engine) = CreateTask (WriteObject ("map", NativeAotObjectTestFixture.CreateBlob ("test/Live"))); + task.OutputFile = directory; + AssertFailure (task, engine, directory); + } + + static void AssertFailure (ExtractTypeMapKeysFromNativeAotObject task, TypeMapTaskBuildEngine engine, string? file = null) + { + file ??= task.NativeObjectFiles.Length == 0 ? nameof (task.NativeObjectFiles) : task.NativeObjectFiles [0].ItemSpec; + Assert.False (task.Execute ()); + Assert.Contains (engine.Errors, error => error.Code == "XA4327" && + (file.Length == 0 || error.Message != null && error.Message.Contains (file, StringComparison.Ordinal))); + Assert.False (File.Exists (task.OutputFile)); + } + + (MetadataTask task, TypeMapTaskBuildEngine engine) CreateTask (params string [] paths) + { + var engine = new TypeMapTaskBuildEngine (); + return (new MetadataTask (path => metadata [path].ToJsonString ()) { + BuildEngine = engine, + NativeObjectFiles = paths.Select (path => new TaskItem (path)).ToArray (), + LlvmReadObjPath = Path.Combine (directory, "llvm-readobj-placeholder"), + OutputFile = Path.Combine (directory, "output", "keys.txt"), + }, engine); + } + + string WriteObject (string name, byte [] blob) + { + string path = Path.Combine (directory, name + ".o"); + byte [] bytes = new byte [SectionOffset + SymbolOffset + blob.Length + 64]; + Encoding.UTF8.GetBytes ("unrelated/Before\0__external_type_map__\0").CopyTo (bytes, 0); + blob.CopyTo (bytes, SectionOffset + SymbolOffset); + Encoding.UTF8.GetBytes ("unrelated/After\0").CopyTo (bytes, SectionOffset + SymbolOffset + blob.Length); + File.WriteAllBytes (path, bytes); + metadata [path] = Array (JsonNode.Parse ($$$$""" + [{ + "FileSummary": {"File":"fixture.o","Format":"elf64-littleaarch64","Arch":"aarch64","AddressSize":"64bit"}, + "ElfHeader": {"Type":"Relocatable (0x1)","Ident":{"DataEncoding":{"Name":"LittleEndian","Value":1}}}, + "Sections": [ + {"Section":{"Index":2,"Name":{"Name":".rodata","Value":9},"Type":{"Name":"SHT_PROGBITS","Value":1},"Offset":0,"Size":64}}, + {"Section":{"Index":1,"Name":{"Name":".rodata","Value":1},"Type":{"Name":"SHT_PROGBITS","Value":1},"Offset":{{{{SectionOffset}}}},"Size":{{{{SymbolOffset + blob.Length + 64}}}}}} + ], + "Symbols": [ + {"Symbol":{"Name":{"Name":"unrelated/Removed","Value":0},"Value":0,"Size":64,"Section":{"Name":".rodata","Value":2}}}, + {"Symbol":{"Name":{"Name":"__external_type_map__","Value":0},"Value":{{{{SymbolOffset}}}},"Size":{{{{blob.Length}}}},"Section":{"Name":".rodata","Value":1}}}, + {"Symbol":{"Name":{"Name":"__external_type_map__not_a_map","Value":0},"Value":0,"Size":64,"Section":{"Name":".rodata","Value":2}}} + ] + }] + """)); + return path; + } + + JsonObject Document (string path) => Object (metadata [path] [0]); + JsonObject Summary (string path) => Object (Document (path) ["FileSummary"]); + JsonObject Section (string path) => Object (Object (Array (Document (path) ["Sections"]) [1]) ["Section"]); + JsonObject Symbol (string path) => Object (Object (Array (Document (path) ["Symbols"]) [1]) ["Symbol"]); + static JsonObject Object (JsonNode? node) => node as JsonObject ?? throw new InvalidOperationException ("Expected an object fixture."); + static JsonArray Array (JsonNode? node) => node as JsonArray ?? throw new InvalidOperationException ("Expected an array fixture."); + + sealed class MetadataTask (Func reader) : ExtractTypeMapKeysFromNativeAotObject + { + public Func MetadataReader { get; set; } = reader; + + protected override Task ReadObjectMetadataAsync (string objectFile) => Task.FromResult (MetadataReader (objectFile)); + } +} diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/NativeAotObjectTestFixture.Tool.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/NativeAotObjectTestFixture.Tool.cs new file mode 100644 index 00000000000..0a2ea93bfe0 --- /dev/null +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/NativeAotObjectTestFixture.Tool.cs @@ -0,0 +1,50 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using Xamarin.Android.Tools; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap.Tests; + +static partial class NativeAotObjectTestFixture +{ + public static string WriteObject (string directory, string name, string llvmReadObjPath, params string [] keys) + { + string? toolDirectory = Path.GetDirectoryName (llvmReadObjPath); + if (string.IsNullOrEmpty (toolDirectory)) { + throw new ArgumentException ("Expected a full path to llvm-readobj.", nameof (llvmReadObjPath)); + } + string clang = Path.Combine (toolDirectory, OperatingSystem.IsWindows () ? "clang.exe" : "clang"); + Directory.CreateDirectory (directory); + string objectPath = Path.ChangeExtension (Path.Combine (directory, name), ".o"); + string sourcePath = Path.ChangeExtension (objectPath, ".s"); + byte [] blob = CreateBlob (keys); + var source = new StringBuilder (""" + .section .rodata,"a",%progbits + .globl __external_type_map__ + .hidden __external_type_map__ + .type __external_type_map__,%object + __external_type_map__: + + """); + for (int i = 0; i < blob.Length; i += 32) { + source.Append (".byte "); + source.AppendJoin (",", blob.Skip (i).Take (32).Select (b => "0x" + b.ToString ("x2", CultureInfo.InvariantCulture))); + source.Append ('\n'); + } + source.Append (".size __external_type_map__, . - __external_type_map__\n"); + File.WriteAllText (sourcePath, source.ToString (), new UTF8Encoding (false)); + + using var stdout = new StringWriter (CultureInfo.InvariantCulture); + using var stderr = new StringWriter (CultureInfo.InvariantCulture); + var startInfo = ProcessUtils.CreateProcessStartInfo (clang, + "--target=aarch64-linux-android", "-c", "-x", "assembler", sourcePath, "-o", objectPath); + int exitCode = ProcessUtils.StartProcess (startInfo, stdout, stderr, CancellationToken.None).GetAwaiter ().GetResult (); + if (exitCode != 0) { + throw new InvalidOperationException ($"clang exited with code {exitCode}: {stderr}{stdout}"); + } + return objectPath; + } +} diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/NativeAotObjectTestFixture.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/NativeAotObjectTestFixture.cs new file mode 100644 index 00000000000..83166246ab5 --- /dev/null +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/NativeAotObjectTestFixture.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap.Tests; + +internal static partial class NativeAotObjectTestFixture +{ + internal static byte [] CreateBlob (params string [] keys) => CreateGroups (keys); + + internal static byte [] CreateGroups (params string [] [] groups) => + CreateTable (groups.Select ((keys, index) => + CreateGroup (CreateTable (keys.Select (key => CreateKey (key)).ToArray ()), typeIndex: (uint) index)).ToArray ()); + + internal static byte [] CreateGroup (byte [] inner, uint state = 1, uint typeIndex = 0) => + [.. EncodeUnsigned (typeIndex), .. EncodeUnsigned (state), .. inner]; + + internal static byte [] CreateKey (string key, uint typeIndex = 0) + { + byte [] utf8 = new UTF8Encoding (false, true).GetBytes (key); + return [.. EncodeUnsigned ((uint) utf8.Length), .. utf8, .. EncodeUnsigned (typeIndex)]; + } + + internal static byte [] CreateTable (byte [] [] payloads, int indexWidth = 1, int bucketShift = 0, int relativeWidth = 5, bool backwards = false) + { + int bucketCount = 1 << bucketShift; + int indexTag = indexWidth switch { 1 => 0, 2 => 1, 4 => 2, _ => throw new ArgumentOutOfRangeException (nameof (indexWidth)) }; + var bytes = new List { (byte) ((bucketShift << 2) | indexTag) }; + bytes.AddRange (new byte [(bucketCount + 1) * indexWidth]); + var targets = new int [payloads.Length]; + var pointers = new List<(int address, int target)> (); + if (backwards) { + AppendPayloads (); + } + for (int bucket = 0; bucket <= bucketCount; bucket++) { + uint index = checked ((uint) (bytes.Count - 1)); + if (indexWidth < 4 && index >= 1u << (8 * indexWidth)) { + throw new ArgumentOutOfRangeException (nameof (indexWidth), "Fixture bucket offset does not fit."); + } + for (int i = 0; i < indexWidth; i++) { + bytes [1 + bucket * indexWidth + i] = (byte) (index >> (8 * i)); + } + if (bucket == bucketCount) { + break; + } + for (int entry = bucket; entry < payloads.Length; entry += bucketCount) { + bytes.Add ((byte) entry); + pointers.Add ((bytes.Count, entry)); + bytes.AddRange (new byte [relativeWidth]); + } + } + if (!backwards) { + AppendPayloads (); + } + foreach (var (address, target) in pointers) { + // NativeFormat offsets are relative to the integer's address, not its end. + int relative = targets [target] - address; + int bits = relativeWidth == 5 ? 32 : relativeWidth * 7; + if (bits < 32 && (relative < -(1L << (bits - 1)) || relative >= 1L << (bits - 1))) { + throw new ArgumentOutOfRangeException (nameof (relativeWidth), "Fixture relative offset does not fit."); + } + byte [] encoded = EncodeInteger (unchecked ((uint) relative), relativeWidth); + for (int i = 0; i < encoded.Length; i++) { + bytes [address + i] = encoded [i]; + } + } + return bytes.ToArray (); + + void AppendPayloads () + { + for (int i = 0; i < payloads.Length; i++) { + targets [i] = bytes.Count; + bytes.AddRange (payloads [i]); + } + } + } + + internal static byte [] EncodeUnsigned (uint value) + { + int width = value < 1u << 7 ? 1 : value < 1u << 14 ? 2 : value < 1u << 21 ? 3 : value < 1u << 28 ? 4 : 5; + return EncodeInteger (value, width); + } + + static byte [] EncodeInteger (uint value, int width) + { + var result = new byte [width]; + if (width == 5) { + result [0] = 15; + for (int i = 0; i < 4; i++) { + result [i + 1] = (byte) (value >> (8 * i)); + } + } else { + uint encoded = (value << width) | ((1u << (width - 1)) - 1); + for (int i = 0; i < width; i++) { + result [i] = (byte) (encoded >> (8 * i)); + } + } + return result; + } +} From 81f0b033a2518eb46a82e5a656a5054148f24021 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 18 Sep 2026 10:25:03 +0200 Subject: [PATCH 21/25] Scope CoreCLR R8 member retention to retained JNI types Keep class-root generation separate from scoped JNI member rules and preserve explicit CoreCLR runtime bootstrap members. Leave NativeAOT's existing policy unchanged. Normalize the modern task assembly path for incremental inputs and cover no-op/deleted-output builds plus real inherited JNI method/field access and Java-only dependency removal. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Documentation/guides/D8andR8.md | 28 +++- ...erateTypeMapMemberProguardConfiguration.cs | 11 ++ .../GenerateTypeMapProguardConfiguration.cs | 5 +- ...osoft.Android.Sdk.TypeMap.Proguard.targets | 29 +++- .../Resources/proguard_typemap_coreclr.cfg | 25 ++++ src/Xamarin.Android.Build.Tasks/Tasks/R8.cs | 3 +- .../Tasks/R8Tests.cs | 14 +- .../TrimmableTypeMapBuildTests.cs | 19 ++- .../Xamarin.Android.D8.targets | 3 +- .../Tests/ScopedTypeMapR8Tests.cs | 128 ++++++++++++++++++ .../Generator/TypeMapProguardTests.cs | 69 +++++++++- 11 files changed, 319 insertions(+), 15 deletions(-) create mode 100644 src/Microsoft.Android.Build.Tasks/Tasks/GenerateTypeMapMemberProguardConfiguration.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Resources/proguard_typemap_coreclr.cfg create mode 100644 tests/MSBuildDeviceIntegration/Tests/ScopedTypeMapR8Tests.cs diff --git a/Documentation/guides/D8andR8.md b/Documentation/guides/D8andR8.md index b2829f9202d..5612a33b65f 100644 --- a/Documentation/guides/D8andR8.md +++ b/Documentation/guides/D8andR8.md @@ -43,12 +43,28 @@ union of keys files and writes deterministic class-only rules to -keep class example.Outer$Inner ``` -The generator never emits member rules or global R8 options. The separate -`proguard_typemap.cfg` runtime configuration retains members of surviving -classes, including surviving third-party classes, while allowing unused -classes to disappear. It keeps explicit runtime bootstrap roots rather than -whole wrapper packages. User Java source retention and application/library -ProGuard rules remain separate. +The class-root generator never emits member rules or global R8 options. +CoreCLR uses a separate `GenerateTypeMapMemberProguardConfiguration` task to +write `proguard/proguard_typemap_members.cfg` from the same retained keys: + +```text +-keepclassmembers class android.app.Activity { *; } +-keepclassmembers class example.Outer$Inner { *; } +``` + +This preserves JNI-facing methods, constructors, and fields without also +preserving every member of Java-only dependencies. R8's member matching +includes inherited accessible methods and superclass fields. The separate +`proguard_typemap_coreclr.cfg` configuration preserves runtime bootstrap +classes and their members, plus framework-driven view members. Dynamic JNI +or reflection access to types or members not represented by retained managed +bindings still needs application/library ProGuard rules. + +NativeAOT continues to use `proguard_typemap.cfg`, which retains members of +all surviving classes, including third-party classes, while allowing unused +classes to disappear. Both policies keep explicit runtime bootstrap roots +rather than whole wrapper packages. User Java source retention and +application/library ProGuard rules remain separate. The temporary private override `_AndroidEnableTypemapR8Trimming` controls this pipeline. It replaces the old NativeAOT-specific trimming and ProGuard switches. diff --git a/src/Microsoft.Android.Build.Tasks/Tasks/GenerateTypeMapMemberProguardConfiguration.cs b/src/Microsoft.Android.Build.Tasks/Tasks/GenerateTypeMapMemberProguardConfiguration.cs new file mode 100644 index 00000000000..f7c7f06ff84 --- /dev/null +++ b/src/Microsoft.Android.Build.Tasks/Tasks/GenerateTypeMapMemberProguardConfiguration.cs @@ -0,0 +1,11 @@ +using System.IO; + +namespace Microsoft.Android.Tasks; + +public class GenerateTypeMapMemberProguardConfiguration : GenerateTypeMapProguardConfiguration +{ + public override string TaskPrefix => "GTMMPC"; + + protected override void WriteClassRule (TextWriter writer, string name) => + writer.WriteLine ($"-keepclassmembers class {name} {{ *; }}"); +} diff --git a/src/Microsoft.Android.Build.Tasks/Tasks/GenerateTypeMapProguardConfiguration.cs b/src/Microsoft.Android.Build.Tasks/Tasks/GenerateTypeMapProguardConfiguration.cs index 65bdaf88d56..f636f1e4787 100644 --- a/src/Microsoft.Android.Build.Tasks/Tasks/GenerateTypeMapProguardConfiguration.cs +++ b/src/Microsoft.Android.Build.Tasks/Tasks/GenerateTypeMapProguardConfiguration.cs @@ -55,7 +55,7 @@ public override bool RunTask () } using var writer = new StreamWriter (OutputFile, append: false, new UTF8Encoding (false)) { NewLine = "\n" }; foreach (var name in classes) { - writer.WriteLine ($"-keep class {name}"); + WriteClassRule (writer, name); } } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is DecoderFallbackException) { Log.LogCodedError ("XA4328", Properties.Resources.XA4328, currentFile, ex.Message); @@ -64,6 +64,9 @@ public override bool RunTask () return !Log.HasLoggedErrors; } + protected virtual void WriteClassRule (TextWriter writer, string name) => + writer.WriteLine ($"-keep class {name}"); + internal static bool IsClassName (string name) { bool first = true; diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Proguard.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Proguard.targets index 408565b6758..51c1dd5b730 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Proguard.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Proguard.targets @@ -4,6 +4,7 @@ + <_CompileToDalvikDependsOnTargets>$(_CompileToDalvikDependsOnTargets);_AndroidGenerateTypeMapProguardConfiguration @@ -17,6 +18,7 @@ <_AndroidTypeMapProguardConfiguration>$(_ProguardProjectConfiguration) <_AndroidUseTypeMapProguardConfiguration Condition=" '$(ProguardConfigFiles)' == '' ">true + <_AndroidUseScopedTypeMapMembers Condition=" '$(_AndroidRuntime)' == 'CoreCLR' and '$(_AndroidUseTypeMapProguardConfiguration)' == 'true' ">true @@ -50,6 +52,7 @@ <_AndroidTypeMapKeysFile>$(IntermediateOutputPath)typemap.keys.txt <_AndroidTypeMapKeysInputsFile>$(IntermediateOutputPath)typemap.keys.inputs + <_AndroidTypeMapTasksAssemblyFile>$([MSBuild]::NormalizePath('$(MSBuildThisFileDirectory)', '$(_MicrosoftAndroidBuildTasksAssembly)')) <_AndroidTypeMapKeysKind Condition=" '$(_AndroidRuntime)' == 'NativeAOT' ">dgml <_AndroidTypeMapKeysKind Condition=" '$(_AndroidRuntime)' == 'CoreCLR' and '$(AndroidTypeMapImplementation)' != 'trimmable' ">llvm-ir <_AndroidTypeMapKeysKind Condition=" '$(_AndroidRuntime)' == 'CoreCLR' and '$(AndroidTypeMapImplementation)' == 'trimmable' ">assemblies @@ -83,7 +86,7 @@ + + + + <_AndroidTypeMapMemberProguardConfiguration>$(IntermediateOutputPath)proguard\proguard_typemap_members.cfg + + + + + + + <_ProguardConfiguration Include="$(_AndroidTypeMapMemberProguardConfiguration)" /> + + + diff --git a/src/Xamarin.Android.Build.Tasks/Resources/proguard_typemap_coreclr.cfg b/src/Xamarin.Android.Build.Tasks/Resources/proguard_typemap_coreclr.cfg new file mode 100644 index 00000000000..b9a0067a2ac --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Resources/proguard_typemap_coreclr.cfg @@ -0,0 +1,25 @@ +# Members of JNI-facing types are preserved by proguard_typemap_members.cfg. +# Java-only dependencies remain eligible for member shrinking. + +# Java.Interop resolves these support classes and their members by name. +-keep class net.dot.jni.ManagedPeer { *; } +-keep interface net.dot.jni.GCUserPeerable { *; } +-keep class net.dot.jni.internal.JavaProxyObject { *; } +-keep class net.dot.jni.internal.JavaProxyThrowable { *; } + +# Manifest providers and native runtime bootstrap entry points. +-keep class mono.MonoRuntimeProvider* { *; } +-keep class mono.MonoPackageManager { *; } +-keep class mono.MonoPackageManager_Resources { *; } +-keep class mono.android.Runtime { *; } +-keep interface mono.android.IGCUserPeer { *; } +-keep class mono.android.GCUserPeer { *; } +-keep class net.dot.android.ApplicationRegistration { *; } +-keep class net.dot.android.crypto.** { *; (...); } + +# Framework-driven view inflation and property setters. +-keepclassmembers class * extends android.view.View { + *** set*(...); + (android.content.Context,android.util.AttributeSet); + (android.content.Context,android.util.AttributeSet,int); +} diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs index ee262d7d939..b8614604b81 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/R8.cs @@ -37,6 +37,7 @@ public class R8 : D8 public string? BuildMetadataFileOutput { get; set; } public ITaskItem []? ProguardConfigurationFiles { get; set; } public bool UseTypeMapProguardConfiguration { get; set; } + public bool UseScopedTypeMapMembers { get; set; } public string ObfuscationMode { get; set; } = "private-members"; // User-authored AndroidJavaSource (Bind != true) .java files. These have no managed peer and are @@ -199,7 +200,7 @@ protected override string CreateResponseFile () xamcfg.WriteLine (); xamcfg.Flush (); if (UseTypeMapProguardConfiguration) { - using var stream = GetEmbeddedResourceStream ("proguard_typemap.cfg"); + using var stream = GetEmbeddedResourceStream (UseScopedTypeMapMembers ? "proguard_typemap_coreclr.cfg" : "proguard_typemap.cfg"); stream.CopyTo (xamcfg.BaseStream); } else { using var stream = GetEmbeddedResourceStream ("proguard_xamarin.cfg"); diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/R8Tests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/R8Tests.cs index 9a634049435..93fe2fadb9a 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/R8Tests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/R8Tests.cs @@ -79,8 +79,9 @@ public void WriteDisabledObfuscationRules () Assert.AreEqual ("-dontobfuscate" + System.Environment.NewLine, writer.ToString ()); } - [Test] - public void RetainedTypeMapRulesDoNotRootAllAcwsOrObfuscatePrivateMembers () + [TestCase (false)] + [TestCase (true)] + public void RetainedTypeMapRulesDoNotRootAllAcwsOrObfuscatePrivateMembers (bool scopedMembers) { var directory = Path.Combine (Path.GetTempPath (), "R8TypeMap_" + System.Guid.NewGuid ().ToString ("N")); Directory.CreateDirectory (directory); @@ -92,6 +93,7 @@ public void RetainedTypeMapRulesDoNotRootAllAcwsOrObfuscatePrivateMembers () var task = new R8ResponseTestTask { BuildEngine = new MockBuildEngine (TestContext.Out), UseTypeMapProguardConfiguration = true, + UseScopedTypeMapMembers = scopedMembers, EnableShrinking = true, ObfuscationMode = "private-members", AcwMapFile = map, @@ -111,6 +113,14 @@ public void RetainedTypeMapRulesDoNotRootAllAcwsOrObfuscatePrivateMembers () StringAssert.Contains ("-dontobfuscate", common); StringAssert.DoesNotContain ("-keep,allowshrinking,allowoptimization class **", common); StringAssert.DoesNotContain ("-keep class mono.android.**", common); + if (scopedMembers) { + StringAssert.DoesNotContain ("-keepclassmembers class * {", common); + StringAssert.Contains ("-keep class mono.android.Runtime { *; }", common); + StringAssert.Contains ("-keep class net.dot.jni.ManagedPeer { *; }", common); + StringAssert.Contains ("-keep interface mono.android.IGCUserPeer { *; }", common); + } else { + StringAssert.Contains ("-keepclassmembers class * {", common); + } } finally { Directory.Delete (directory, recursive: true); } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs index 91079cfd379..240f4ed7cd7 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs @@ -49,7 +49,16 @@ public void RetainedTypeMapClassRulesDriveR8 (AndroidRuntime runtime, string imp File.ReadAllLines (configuration)); var common = File.ReadAllText (Path.Combine (intermediate, "proguard", "proguard_xamarin.cfg")); StringAssert.Contains ("-dontobfuscate", common); - StringAssert.Contains ("-keepclassmembers class *", common); + var members = Path.Combine (intermediate, "proguard", "proguard_typemap_members.cfg"); + if (runtime == AndroidRuntime.CoreCLR) { + StringAssert.DoesNotContain ("-keepclassmembers class * {", common); + CollectionAssert.AreEqual ( + keys.Select (key => "-keepclassmembers class " + key.Replace ('/', '.') + " { *; }").OrderBy (line => line, StringComparer.Ordinal), + File.ReadAllLines (members)); + } else { + StringAssert.Contains ("-keepclassmembers class * {", common); + FileAssert.DoesNotExist (members); + } StringAssert.DoesNotContain ("-keep class mono.android.**", common); StringAssert.DoesNotContain ("-keep class net.dot.jni.**", common); var dex = Path.Combine (intermediate, "android", "bin", "classes.dex"); @@ -59,6 +68,14 @@ public void RetainedTypeMapClassRulesDriveR8 (AndroidRuntime runtime, string imp Assert.IsTrue (builder.Build (proj)); builder.Output.AssertTargetIsSkipped ("_AndroidExtractTypeMapKeys"); builder.Output.AssertTargetIsSkipped ("_AndroidGenerateTypeMapProguardConfiguration"); + if (runtime == AndroidRuntime.CoreCLR) { + builder.Output.AssertTargetIsSkipped ("_AndroidGenerateTypeMapMemberProguardConfiguration"); + File.Delete (members); + Assert.IsTrue (builder.Build (proj)); + builder.Output.AssertTargetIsNotSkipped ("_AndroidGenerateTypeMapMemberProguardConfiguration"); + builder.Output.AssertTargetIsNotSkipped ("_CompileToDalvik"); + FileAssert.Exists (members); + } File.Delete (configuration); Assert.IsTrue (builder.Build (proj)); builder.Output.AssertTargetIsNotSkipped ("_AndroidGenerateTypeMapProguardConfiguration"); diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.D8.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.D8.targets index 8f698a2033c..6701a613540 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.D8.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.D8.targets @@ -19,7 +19,7 @@ Copyright (C) 2018 Xamarin. All rights reserved. @@ -81,6 +81,7 @@ Copyright (C) 2018 Xamarin. All rights reserved. BuildMetadataFileOutput="$(_AndroidR8BuildMetadataFile)" ProguardConfigurationFiles="@(_ProguardConfiguration)" UseTypeMapProguardConfiguration="$(_AndroidUseTypeMapProguardConfiguration)" + UseScopedTypeMapMembers="$(_AndroidUseScopedTypeMapMembers)" ObfuscationMode="$(AndroidR8ObfuscationMode)" EnableShrinking="$(_R8EnableShrinking)" EnableMultiDex="$(AndroidEnableMultiDex)" diff --git a/tests/MSBuildDeviceIntegration/Tests/ScopedTypeMapR8Tests.cs b/tests/MSBuildDeviceIntegration/Tests/ScopedTypeMapR8Tests.cs new file mode 100644 index 00000000000..14c3e2c699f --- /dev/null +++ b/tests/MSBuildDeviceIntegration/Tests/ScopedTypeMapR8Tests.cs @@ -0,0 +1,128 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using NUnit.Framework; +using Xamarin.Android.Tasks; +using Xamarin.ProjectTools; + +namespace Xamarin.Android.Build.Tests; + +[TestFixture] +[Category ("UsesDevice")] +public class ScopedTypeMapR8Tests : DeviceTest +{ + [TestCase ("llvm-ir")] + [TestCase ("trimmable")] + public void PreservesJniMembersAndShrinksJavaOnlyDependencies (string implementation) + { + var proj = new XamarinAndroidApplicationProject (packageName: PackageUtils.MakePackageName (AndroidRuntime.CoreCLR, "scopedr8_" + implementation.Replace ("-", ""))) { + IsRelease = true, + }; + proj.SetRuntime (AndroidRuntime.CoreCLR); + proj.SetRuntimeIdentifiers ([DeviceAbi]); + proj.SetProperty ("AndroidTypeMapImplementation", implementation); + proj.SetProperty ("AndroidLinkTool", "r8"); + proj.SetProperty ("AndroidR8ObfuscationMode", "disabled"); + proj.SetProperty ("_AndroidEnableTypemapR8Trimming", "true"); + proj.SetProperty ("TrimMode", "full"); + proj.SetProperty ("AndroidSdkDirectory", AndroidSdkResolver.GetAndroidSdkPath ()); + proj.SetProperty ("JavaSdkDirectory", AndroidSdkResolver.GetJavaSdkPath ()); + proj.SetDefaultTargetDevice (); + proj.AndroidJavaSources.Add (JavaSource ("ScopedBase.java", """ + package example; + public class ScopedBase { + public int inheritedField = 11; + private int privateField = 13; + public static int staticField = 17; + public int inheritedMethod() { return 19; } + protected int protectedMethod() { return 23; } + } + """)); + proj.AndroidJavaSources.Add (JavaSource ("ScopedContract.java", """ + package example; + public interface ScopedContract { + default int defaultMethod() { return 29; } + } + """)); + proj.AndroidJavaSources.Add (JavaSource ("ScopedPeer.java", """ + package example; + public class ScopedPeer extends ScopedBase implements ScopedContract { + public ScopedPeer() {} + public int keptMethod() { return Helper.used(); } + public int jniOnly() { return 3; } + } + class Helper { + static int used() { return 7; } + static int unused() { return UnusedDependency.value(); } + } + class UnusedDependency { + static int value() { return 42; } + } + """)); + var marker = "R8_SCOPED_MEMBERS_PASS " + Guid.NewGuid ().ToString ("N"); + proj.MainActivity = proj.DefaultMainActivity.Replace ("//${AFTER_ONCREATE}", """ + using var peer = new Example.ScopedPeer (); + Require (peer.KeptMethod (), 7); + Require (((Example.IScopedContract) peer).DefaultMethod (), 29); + var klass = Android.Runtime.JNIEnv.GetObjectClass (peer.Handle); + try { + Require (Invoke ("jniOnly"), 3); + Require (Invoke ("inheritedMethod"), 19); + Require (Invoke ("protectedMethod"), 23); + Require (Invoke ("defaultMethod"), 29); + Require (ReadField ("inheritedField"), 11); + Require (ReadField ("privateField"), 13); + var field = Android.Runtime.JNIEnv.GetStaticFieldID (klass, "staticField", "I"); + Require (Android.Runtime.JNIEnv.GetStaticIntField (klass, field), 17); + Console.WriteLine ("${MARKER}"); + } finally { + Android.Runtime.JNIEnv.DeleteLocalRef (klass); + } + + int Invoke (string name) { + var method = Android.Runtime.JNIEnv.GetMethodID (klass, name, "()I"); + return Android.Runtime.JNIEnv.CallIntMethod (peer.Handle, method); + } + int ReadField (string name) { + var field = Android.Runtime.JNIEnv.GetFieldID (klass, name, "I"); + return Android.Runtime.JNIEnv.GetIntField (peer.Handle, field); + } + static void Require (int actual, int expected) { + if (actual != expected) { + throw new InvalidOperationException ($"JNI result {actual}, expected {expected}."); + } + } + """.Replace ("${MARKER}", marker, StringComparison.Ordinal)); + + using var builder = CreateApkBuilder (); + bool installed = false; + try { + installed = builder.Install (proj); + Assert.IsTrue (installed, "The scoped-retention app should install."); + var projectDirectory = Path.Combine (Root, builder.ProjectDirectory); + var dexFiles = Directory.GetFiles (Path.Combine (projectDirectory, proj.IntermediateOutputPath), "classes*.dex", SearchOption.AllDirectories); + Assert.IsNotEmpty (dexFiles); + Assert.IsTrue (dexFiles.Any (dex => DexUtils.ContainsClassWithMethod ("Lexample/Helper;", "used", "()I", dex, AndroidSdkPath))); + Assert.IsFalse (dexFiles.Any (dex => DexUtils.ContainsClassWithMethod ("Lexample/Helper;", "unused", "()I", dex, AndroidSdkPath)), + "Preserving JNI-facing peers must not keep unused members of Java-only dependencies."); + Assert.IsFalse (dexFiles.Any (dex => DexUtils.ContainsClass ("Lexample/UnusedDependency;", dex, AndroidSdkPath))); + Assert.IsTrue (MonitorAdbLogcat (line => line.Contains (marker, StringComparison.Ordinal), + Path.Combine (projectDirectory, "scoped-members-logcat.log"), ActivityStartTimeoutInSeconds, + onMonitoringStarted: () => StartActivityAndAssert (proj)), + "The app must successfully invoke preserved JNI methods and fields, including inherited members."); + } finally { + if (installed) { + RunAdbCommand ($"uninstall {proj.PackageName}"); + } + } + } + + static AndroidItem.AndroidJavaSource JavaSource (string name, string source) => new (name) { + Encoding = Encoding.ASCII, + TextContent = () => source, + Metadata = { + { "Bind", "True" }, + }, + }; +} diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs index c34d2c9a99e..bb89bfdf3ab 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs @@ -28,6 +28,36 @@ public void GeneratorUnionsCanonicalKeysAndWritesOnlyClassRules () Assert.Equal (new UTF8Encoding (false).GetBytes (expected), File.ReadAllBytes (task.OutputFile)); } + [Fact] + public void MemberGeneratorScopesRulesToCanonicalKeys () + { + var task = new GenerateTypeMapMemberProguardConfiguration { + BuildEngine = engine, + TypeMapKeyFiles = [ + new TaskItem (Write ("first.keys", "test/Peer\ntest/Contract\n")), + new TaskItem (Write ("second.keys", "test/Peer\ntest/Base\n")), + ], + OutputFile = Path.Combine (directory, "members.cfg"), + }; + Assert.True (task.Execute ()); + Assert.Equal ("-keepclassmembers class test.Base { *; }\n-keepclassmembers class test.Contract { *; }\n-keepclassmembers class test.Peer { *; }\n", + File.ReadAllText (task.OutputFile)); + } + + [Fact] + public void MemberGeneratorRejectsInvalidKeysWithoutOverwritingOutput () + { + var output = Write ("members.cfg", "previous output"); + var task = new GenerateTypeMapMemberProguardConfiguration { + BuildEngine = engine, + TypeMapKeyFiles = [new TaskItem (Write ("invalid.keys", "test/Peer\ntest/*\n"))], + OutputFile = output, + }; + Assert.False (task.Execute ()); + Assert.Contains (engine.Errors, error => error.Code == "XA4328"); + Assert.Equal ("previous output", File.ReadAllText (output)); + } + [Theory] [InlineData ("test/*")] [InlineData ("test/Foo { *; }")] @@ -79,16 +109,25 @@ public void LlvmTargetTracksAbiUnionInputListAndDeletedOutputs () Build (project); var keys = Path.Combine (directory, "obj", "typemap.keys.txt"); var rules = Path.Combine (directory, "obj", "proguard", "proguard_project_references.cfg"); + var members = Path.Combine (directory, "obj", "proguard", "proguard_typemap_members.cfg"); Assert.Equal ("-keep class test.Live\n-keep class test.Second\n", File.ReadAllText (rules)); + Assert.Equal ("-keepclassmembers class test.Live { *; }\n-keepclassmembers class test.Second { *; }\n", File.ReadAllText (members)); var firstTime = File.GetLastWriteTimeUtc (keys); var rulesTime = File.GetLastWriteTimeUtc (rules); + var membersTime = File.GetLastWriteTimeUtc (members); Build (project); Assert.Equal (firstTime, File.GetLastWriteTimeUtc (keys)); Assert.Equal (rulesTime, File.GetLastWriteTimeUtc (rules)); + Assert.Equal (membersTime, File.GetLastWriteTimeUtc (members)); + Assert.Contains (members, File.ReadAllText (Path.Combine (directory, "writes.txt"))); Build (project, "-p:OneAbi=true"); Assert.Equal ("test/Live\n", File.ReadAllText (keys)); Assert.Equal ("-keep class test.Live\n", File.ReadAllText (rules)); + Assert.Equal ("-keepclassmembers class test.Live { *; }\n", File.ReadAllText (members)); + File.Delete (members); + Build (project, "-p:OneAbi=true"); + Assert.Equal ("-keepclassmembers class test.Live { *; }\n", File.ReadAllText (members)); File.Delete (keys); Build (project, "-p:OneAbi=true"); Assert.Equal ("test/Live\n", File.ReadAllText (keys)); @@ -98,11 +137,32 @@ public void LlvmTargetTracksAbiUnionInputListAndDeletedOutputs () Write ("first.ll", "@java_type_names = dso_local local_unnamed_addr constant [13 x i8] c\"test/Changed\\00\", align 1\n"); Build (project, "-p:OneAbi=true"); Assert.Equal ("-keep class test.Changed\n", File.ReadAllText (rules)); + Assert.Equal ("-keepclassmembers class test.Changed { *; }\n", File.ReadAllText (members)); var writes = File.ReadAllText (Path.Combine (directory, "writes.txt")); Assert.Contains ("typemap.keys.txt", writes); Assert.Contains ("typemap.keys.inputs", writes); Assert.Contains ("proguard_project_references.cfg", writes); + Assert.Contains ("proguard_typemap_members.cfg", writes); + } + + [Fact] + public void RelativeTaskAssemblyPathDoesNotInvalidateIncrementalOutputs () + { + Write ("first.ll", "@java_type_names = dso_local local_unnamed_addr constant [10 x i8] c\"test/Live\\00\", align 1\n"); + var project = CreateProject ("CoreCLR", "llvm-ir", + new XElement ("_TypeMapAssemblySource", new XAttribute ("Include", "$(MSBuildProjectDirectory)/first.ll"))); + var targetsDirectory = Path.Combine (RepositoryDirectory (), "src", "Xamarin.Android.Build.Tasks", "Microsoft.Android.Sdk", "targets"); + var assembly = Path.GetRelativePath (targetsDirectory, typeof (GenerateTypeMapProguardConfiguration).Assembly.Location); + var argument = "-p:_MicrosoftAndroidBuildTasksAssembly=" + assembly; + Build (project, argument); + var keys = Path.Combine (directory, "obj", "typemap.keys.txt"); + var members = Path.Combine (directory, "obj", "proguard", "proguard_typemap_members.cfg"); + var keysTime = File.GetLastWriteTimeUtc (keys); + var membersTime = File.GetLastWriteTimeUtc (members); + Build (project, argument); + Assert.Equal (keysTime, File.GetLastWriteTimeUtc (keys)); + Assert.Equal (membersTime, File.GetLastWriteTimeUtc (members)); } [Fact] @@ -119,6 +179,7 @@ public void DgmlTargetUnionsRequestedGraphsAndHonorsDisabledTrimming () Build (project); var rules = Path.Combine (directory, "obj", "proguard", "proguard_project_references.cfg"); Assert.Equal ("-keep class test.Live\n-keep class test.Second\n", File.ReadAllText (rules)); + Assert.False (File.Exists (Path.Combine (directory, "obj", "proguard", "proguard_typemap_members.cfg"))); Build (project, "-p:_AndroidEnableTypemapR8Trimming=false"); Assert.Contains ("legacy ACW configuration", File.ReadAllText (rules)); File.Delete (Path.Combine (directory, "second.dgml")); @@ -137,12 +198,16 @@ public void RuntimeSwitchCannotReuseAnotherRepresentation () new XElement ("ResolvedFileToPublish", new XAttribute ("Include", "app.so"), new XAttribute ("AndroidTypeMapDgmlFile", "$(MSBuildProjectDirectory)/app.dgml"))); var rules = Path.Combine (directory, "obj", "proguard", "proguard_project_references.cfg"); + var members = Path.Combine (directory, "obj", "proguard", "proguard_typemap_members.cfg"); Build (project); Assert.Equal ("-keep class test.Llvm\n", File.ReadAllText (rules)); + Assert.Contains ("Members=" + members, File.ReadAllText (Path.Combine (directory, "writes.txt"))); Build (project, "-p:_AndroidRuntime=NativeAOT", "-p:AndroidTypeMapImplementation=trimmable"); Assert.Equal ("-keep class test.Native\n", File.ReadAllText (rules)); + Assert.DoesNotContain ("Members=" + members, File.ReadAllText (Path.Combine (directory, "writes.txt"))); Build (project); Assert.Equal ("-keep class test.Llvm\n", File.ReadAllText (rules)); + Assert.Equal ("-keepclassmembers class test.Llvm { *; }\n", File.ReadAllText (members)); } [Fact] @@ -155,6 +220,7 @@ public void CompleteConfigurationOverridePreservesLegacyR8Policy () Assert.Contains ("UseTypeMap=true", File.ReadAllText (Path.Combine (directory, "writes.txt"))); Build (project, "-p:ProguardConfigFiles=custom.cfg"); Assert.DoesNotContain ("UseTypeMap=true", File.ReadAllText (Path.Combine (directory, "writes.txt"))); + Assert.DoesNotContain ("Members=" + Path.Combine (directory, "obj"), File.ReadAllText (Path.Combine (directory, "writes.txt"))); } [Theory] @@ -167,6 +233,7 @@ public void DisabledPipelineNeedsNoTypemapInputsOrModernTaskAssembly (string run var project = CreateProject (runtime, representation); Build (project, "-p:_AndroidEnableTypemapR8Trimming=false", "-p:_MicrosoftAndroidBuildTasksAssembly=missing.dll"); Assert.False (File.Exists (Path.Combine (directory, "obj", "typemap.keys.txt"))); + Assert.False (File.Exists (Path.Combine (directory, "obj", "proguard", "proguard_typemap_members.cfg"))); Assert.DoesNotContain ("UseTypeMap=true", File.ReadAllText (Path.Combine (directory, "writes.txt"))); if (runtime == "NativeAOT") { Assert.Equal ("# Class roots are supplied by the legacy ACW configuration.", @@ -379,7 +446,7 @@ string CreateProject (string runtime, string representation, params XElement [] new XElement ("Target", new XAttribute ("Name", "Build"), new XAttribute ("DependsOnTargets", "_CreatePropertiesCache;_CalculateProguardConfigurationFiles;_AndroidGenerateTypeMapProguardConfiguration"), new XElement ("WriteLinesToFile", new XAttribute ("File", "$(MSBuildProjectDirectory)/writes.txt"), - new XAttribute ("Lines", "@(FileWrites);UseTypeMap=$(_AndroidUseTypeMapProguardConfiguration)"), new XAttribute ("Overwrite", "true"))))) + new XAttribute ("Lines", "@(FileWrites);UseTypeMap=$(_AndroidUseTypeMapProguardConfiguration);@(_ProguardConfiguration->'Members=%(Identity)')"), new XAttribute ("Overwrite", "true"))))) .Save (path); return path; } From ef0bcd4d5e2d69886248ac4a8b4bb5a5c3932e31 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 18 Sep 2026 10:27:10 +0200 Subject: [PATCH 22/25] Select NativeAOT Java typemap groups through LLVM fixup symbols Stream relevant LLVM symbol records and bound relocation queries to avoid whole-symbol/relocation JSON allocations. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ExtractTypeMapKeysFromNativeAotObject.cs | 335 ++++++++++++++++-- .../Utilities/NativeAotTypeMapReader.cs | 17 +- ...ractTypeMapKeysFromNativeAotObjectTests.cs | 224 +++++++++++- .../NativeAotObjectTestFixture.Tool.cs | 27 +- 4 files changed, 572 insertions(+), 31 deletions(-) diff --git a/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromNativeAotObject.cs b/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromNativeAotObject.cs index d5fe9a2294d..13646a0ed87 100644 --- a/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromNativeAotObject.cs +++ b/src/Microsoft.Android.Build.Tasks/Tasks/ExtractTypeMapKeysFromNativeAotObject.cs @@ -16,6 +16,8 @@ namespace Microsoft.Android.Tasks; public class ExtractTypeMapKeysFromNativeAotObject : AsyncTask { const string TypeMapSymbol = "__external_type_map__"; + const string CommonFixupsSymbol = "__external_CommonFixupsTable_references"; + const long SectionGroupFlag = 0x200; public override string TaskPrefix => "ETMKNAO"; @@ -47,7 +49,7 @@ public override async Task RunTaskAsync () using var stream = File.OpenRead (currentFile); string metadata = await ReadObjectMetadataAsync (currentFile).ConfigureAwait (false); using var document = JsonDocument.Parse (metadata); - ReadKeys (stream, document.RootElement, keys); + await ReadKeysAsync (currentFile, stream, document.RootElement, keys).ConfigureAwait (false); } CancellationToken.ThrowIfCancellationRequested (); @@ -71,15 +73,222 @@ protected virtual async Task ReadObjectMetadataAsync (string objectFile) using var stdout = new StringWriter (CultureInfo.InvariantCulture); using var stderr = new StringWriter (CultureInfo.InvariantCulture); var startInfo = ProcessUtils.CreateProcessStartInfo (LlvmReadObjPath, - "--elf-output-style=JSON", "--file-headers", "--sections", "--symbols", Path.GetFullPath (objectFile)); + "--elf-output-style=JSON", "--file-headers", "--sections", Path.GetFullPath (objectFile)); int exitCode = await ProcessUtils.StartProcess (startInfo, stdout, stderr, CancellationToken).ConfigureAwait (false); if (exitCode != 0) { throw new InvalidOperationException ($"llvm-readobj exited with code {exitCode}: {stderr}"); } + + using var document = JsonDocument.Parse (stdout.ToString ()); + if (document.RootElement.ValueKind != JsonValueKind.Array || document.RootElement.GetArrayLength () != 1) { + throw new BadImageFormatException ("Expected llvm-readobj metadata for one native object."); + } + var file = document.RootElement [0]; + long? sectionIndex = null; + foreach (var section in ReadSections (file).Values) { + if (Text (Property (section, "Name"), "Name") == ".rodata" && + (Number (Property (section, "Flags"), "Value") & SectionGroupFlag) == 0) { + if (sectionIndex != null) { + throw new BadImageFormatException ("Ambiguous NativeAOT read-only data section."); + } + sectionIndex = Number (section, "Index"); + } + } + if (sectionIndex is not long dataSection) { + throw new BadImageFormatException ("The NativeAOT read-only data section was not found."); + } + + // A real app can have hundreds of MB of symbol JSON. Keep only the two + // non-COMDAT .rodata symbols emitted by ILC, not a DOM of the entire symbol table. + using var symbols = new TypeMapSymbolWriter (); + await RunObjdumpAsync (symbols, "--syms", Path.GetFullPath (objectFile)).ConfigureAwait (false); + symbols.Flush (); + using var result = new MemoryStream (); + using (var json = new Utf8JsonWriter (result)) { + json.WriteStartArray (); + json.WriteStartObject (); + foreach (var property in file.EnumerateObject ()) { + if (property.Name != "Symbols") { + property.WriteTo (json); + } + } + json.WriteStartArray ("Symbols"); + foreach (string line in symbols.Lines) { + string [] fields = line.Split ([' ', '\t'], 6, StringSplitOptions.RemoveEmptyEntries); + if (fields.Length != 6 || fields [2] != "O" || fields [3] != ".rodata") { + continue; + } + if (!long.TryParse (fields [0], NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out long value) || + !long.TryParse (fields [4], NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out long size)) { + throw new BadImageFormatException ("Invalid llvm-objdump type map symbol record."); + } + string name = fields [5].Trim (); + if (name.StartsWith (".hidden ", StringComparison.Ordinal)) { + name = name.Substring (".hidden ".Length).TrimStart (); + } + json.WriteStartObject (); + json.WriteStartObject ("Symbol"); + json.WriteStartObject ("Name"); + json.WriteString ("Name", name); + json.WriteEndObject (); + json.WriteNumber ("Value", value); + json.WriteNumber ("Size", size); + json.WriteStartObject ("Section"); + json.WriteNumber ("Value", dataSection); + json.WriteEndObject (); + json.WriteEndObject (); + json.WriteEndObject (); + } + json.WriteEndArray (); + json.WriteEndObject (); + json.WriteEndArray (); + } + return Encoding.UTF8.GetString (result.ToArray ()); + } + + protected virtual async Task ReadObjectRelocationsAsync (string objectFile, string section, long start, long end) + { + using var stdout = new StringWriter (CultureInfo.InvariantCulture); + await RunObjdumpAsync (stdout, "--reloc", "--section=" + section, + "--start-address=0x" + start.ToString ("x", CultureInfo.InvariantCulture), + "--stop-address=0x" + end.ToString ("x", CultureInfo.InvariantCulture), Path.GetFullPath (objectFile)).ConfigureAwait (false); return stdout.ToString (); } - static void ReadKeys (FileStream stream, JsonElement root, ISet keys) + async Task RunObjdumpAsync (TextWriter stdout, params string [] arguments) + { + string? directory = Path.GetDirectoryName (LlvmReadObjPath); + if (string.IsNullOrEmpty (directory)) { + throw new ArgumentException ("Expected a full path to llvm-readobj.", nameof (LlvmReadObjPath)); + } + string objdump = Path.Combine (directory, OperatingSystem.IsWindows () ? "llvm-objdump.exe" : "llvm-objdump"); + if (!File.Exists (objdump)) { + throw new FileNotFoundException ("llvm-objdump is required alongside llvm-readobj.", objdump); + } + using var stderr = new StringWriter (CultureInfo.InvariantCulture); + var startInfo = ProcessUtils.CreateProcessStartInfo (objdump, arguments); + int exitCode = await ProcessUtils.StartProcess (startInfo, stdout, stderr, CancellationToken).ConfigureAwait (false); + if (exitCode != 0) { + throw new InvalidOperationException ($"llvm-objdump exited with code {exitCode}: {stderr}"); + } + } + + protected virtual async Task> GetJavaTypeMapGroupsAsync ( + string objectFile, JsonElement metadata, string mapSymbol, IReadOnlyCollection groupIndices, long fileLength) + { + var selected = new HashSet (); + if (groupIndices.Count == 0) { + return selected; + } + + string fixupsName = mapSymbol.Substring (0, mapSymbol.Length - TypeMapSymbol.Length) + CommonFixupsSymbol; + var sections = ReadSections (metadata); + JsonElement? fixups = null; + foreach (var item in Property (metadata, "Symbols").EnumerateArray ()) { + var symbol = Property (item, "Symbol"); + if (Text (Property (symbol, "Name"), "Name") == fixupsName) { + if (fixups != null) { + throw new BadImageFormatException ("Duplicate NativeAOT common-fixup symbol."); + } + fixups = symbol; + } + } + if (fixups is not JsonElement fixupSymbol) { + throw new BadImageFormatException ("The NativeAOT common-fixup table was not found."); + } + var region = ReadRegion (fixupSymbol, sections, fileLength); + if (region.Size % 4 != 0) { + throw new BadImageFormatException ("Invalid NativeAOT common-fixup table size."); + } + + string? relocationSection = null; + foreach (var section in sections.Values) { + string type = Text (Property (section, "Type"), "Name"); + if ((type == "SHT_REL" || type == "SHT_RELA") && Number (section, "Info") == region.SectionIndex) { + if (relocationSection != null) { + throw new BadImageFormatException ("Ambiguous NativeAOT common-fixup relocation section."); + } + relocationSection = Text (Property (section, "Name"), "Name"); + } + } + if (relocationSection == null) { + throw new BadImageFormatException ("The NativeAOT common-fixup relocations were not found."); + } + + var slots = new Dictionary (); + long start = long.MaxValue; + long end = 0; + foreach (uint index in groupIndices) { + // All supported Android targets use 32-bit self-relative common fixups. + long relativeOffset = (long) index * 4; + if (relativeOffset >= region.Size) { + throw new BadImageFormatException ("A NativeAOT type map group references an invalid common-fixup index."); + } + long slot = region.SectionRelativeOffset + relativeOffset; + slots.Add (slot, index); + start = Math.Min (start, slot); + end = Math.Max (end, slot + 4); + } + + string architecture = Text (Property (metadata, "FileSummary"), "Arch"); + string relocationType = architecture switch { + "aarch64" => "R_AARCH64_PREL32", + "arm" => "R_ARM_REL32", + "x86_64" => "R_X86_64_PC32", + "i386" => "R_386_PC32", + _ => throw new BadImageFormatException ("Unsupported NativeAOT relocation architecture."), + }; + var resolved = new HashSet (); + string relocations = await ReadObjectRelocationsAsync (objectFile, relocationSection, start, end).ConfigureAwait (false); + using var lines = new StringReader (relocations); + string? line; + while ((line = lines.ReadLine ()) != null) { + string [] fields = line.Split ([' ', '\t'], 3, StringSplitOptions.RemoveEmptyEntries); + if (fields.Length == 0 || !long.TryParse (fields [0], NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out long offset) || + !slots.TryGetValue (offset, out uint index)) { + continue; + } + if (fields.Length != 3 || fields [1] != relocationType || !resolved.Add (index)) { + throw new BadImageFormatException ("Invalid or ambiguous NativeAOT type map group relocation."); + } + string groupSymbol = fields [2].Trim (); + bool isJavaGroup = IsJavaGroupSymbol (groupSymbol); + LogDebugMessage ("NativeAOT type map group {0}: {1} ({2}).", index, groupSymbol, isJavaGroup ? "Java" : "not Java"); + if (isJavaGroup) { + selected.Add (index); + } + } + if (resolved.Count != slots.Count) { + throw new BadImageFormatException ("A NativeAOT type map group relocation was not found."); + } + if (selected.Count == 0) { + throw new BadImageFormatException ("No Java type map group was found in the NativeAOT object."); + } + return selected; + } + + static bool IsJavaGroupSymbol (string symbol) + { + int marker = symbol.IndexOf ("_ZTV", StringComparison.Ordinal); + if (marker < 0) { + return false; + } + int start = marker + 4; + int nameStart = start; + while (nameStart < symbol.Length && symbol [nameStart] >= '0' && symbol [nameStart] <= '9') { + nameStart++; + } + if (nameStart == start) { + return false; + } + string name = symbol.Substring (nameStart); + // Match the group identities emitted by TypeMapAssemblyEmitter, not the keys' + // appearance: the built-in JavaDictionary universe contains managed type names. + return name == "Mono_Android_Java_Lang_Object" || + (name.StartsWith ("_", StringComparison.Ordinal) && name.EndsWith ("_TypeMap___TypeMapAnchor", StringComparison.Ordinal)); + } + + async Task ReadKeysAsync (string objectFile, FileStream stream, JsonElement root, ISet keys) { if (root.ValueKind != JsonValueKind.Array || root.GetArrayLength () != 1) { throw new BadImageFormatException ("Expected llvm-readobj metadata for one native object."); @@ -96,14 +305,7 @@ static void ReadKeys (FileStream stream, JsonElement root, ISet keys) throw new BadImageFormatException ("Expected a little-endian ARM, ARM64, x86, or x64 relocatable ELF object."); } - var sections = new Dictionary (); - var sectionList = Property (file, "Sections"); - foreach (var item in sectionList.EnumerateArray ()) { - var section = Property (item, "Section"); - if (!sections.TryAdd (Number (section, "Index"), section)) { - throw new BadImageFormatException ("Duplicate ELF section index in llvm-readobj output."); - } - } + var sections = ReadSections (file); bool found = false; foreach (var item in Property (file, "Symbols").EnumerateArray ()) { @@ -113,32 +315,53 @@ static void ReadKeys (FileStream stream, JsonElement root, ISet keys) continue; } found = true; - long sectionIndex = Number (Property (symbol, "Section"), "Value"); - if (!sections.TryGetValue (sectionIndex, out var section) || - Text (Property (section, "Type"), "Name") != "SHT_PROGBITS") { - throw new BadImageFormatException ($"NativeAOT type map symbol '{name}' has no file-backed data section."); - } - long sectionOffset = Number (section, "Offset"); - long sectionSize = Number (section, "Size"); - long value = Number (symbol, "Value"); - long size = Number (symbol, "Size"); - if (sectionOffset > stream.Length || sectionSize > stream.Length - sectionOffset || - value > sectionSize || size > sectionSize - value || size == 0 || size > int.MaxValue) { - throw new BadImageFormatException ($"NativeAOT type map symbol '{name}' has an invalid file range."); - } + var region = ReadRegion (symbol, sections, stream.Length); // LLVM supplies the ELF layout. Only the symbol's relocation-free NativeFormat // payload is interpreted here; unrelated strings elsewhere in the object are ignored. - stream.Position = sectionOffset + value; - var data = new byte [(int) size]; + stream.Position = region.FileOffset; + var data = new byte [(int) region.Size]; stream.ReadExactly (data); - new NativeAotTypeMapReader (data).ReadKeys (keys); + var reader = new NativeAotTypeMapReader (data); + var javaGroups = await GetJavaTypeMapGroupsAsync (objectFile, file, name, reader.ReadGroupTypeIndices (), stream.Length).ConfigureAwait (false); + reader.ReadKeys (keys, javaGroups); } if (!found) { throw new BadImageFormatException ("No NativeAOT external type map symbol was found."); } } + static Dictionary ReadSections (JsonElement metadata) + { + var sections = new Dictionary (); + foreach (var item in Property (metadata, "Sections").EnumerateArray ()) { + var section = Property (item, "Section"); + if (!sections.TryAdd (Number (section, "Index"), section)) { + throw new BadImageFormatException ("Duplicate ELF section index in llvm-readobj output."); + } + } + return sections; + } + + static (long FileOffset, long SectionRelativeOffset, long Size, long SectionIndex) ReadRegion ( + JsonElement symbol, Dictionary sections, long fileLength) + { + string name = Text (Property (symbol, "Name"), "Name"); + long sectionIndex = Number (Property (symbol, "Section"), "Value"); + if (!sections.TryGetValue (sectionIndex, out var section) || Text (Property (section, "Type"), "Name") != "SHT_PROGBITS") { + throw new BadImageFormatException ($"NativeAOT symbol '{name}' has no file-backed data section."); + } + long sectionOffset = Number (section, "Offset"); + long sectionSize = Number (section, "Size"); + long value = Number (symbol, "Value"); + long size = Number (symbol, "Size"); + if (sectionOffset > fileLength || sectionSize > fileLength - sectionOffset || + value > sectionSize || size > sectionSize - value || size == 0 || size > int.MaxValue) { + throw new BadImageFormatException ($"NativeAOT symbol '{name}' has an invalid file range."); + } + return (sectionOffset + value, value, size, sectionIndex); + } + static JsonElement Property (JsonElement element, string name) { if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty (name, out var value)) { @@ -164,4 +387,62 @@ static long Number (JsonElement element, string name) } return number; } + + sealed class TypeMapSymbolWriter : TextWriter + { + readonly StringBuilder line = new (); + + public List Lines { get; } = []; + public override Encoding Encoding => Encoding.UTF8; + + public override void Write (char value) + { + if (value == '\n') { + Flush (); + } else { + line.Append (value); + } + } + + public override void Write (char []? buffer, int index, int count) + { + ArgumentNullException.ThrowIfNull (buffer); + Write (buffer.AsSpan (index, count)); + } + + public override void Write (ReadOnlySpan buffer) + { + int newline; + while ((newline = buffer.IndexOf ('\n')) >= 0) { + line.Append (buffer.Slice (0, newline)); + Flush (); + buffer = buffer.Slice (newline + 1); + } + line.Append (buffer); + } + + public override void Flush () + { + while (line.Length > 0 && char.IsWhiteSpace (line [line.Length - 1])) { + line.Length--; + } + if (EndsWith (TypeMapSymbol) || EndsWith (CommonFixupsSymbol)) { + Lines.Add (line.ToString ()); + } + line.Clear (); + } + + bool EndsWith (string suffix) + { + if (line.Length < suffix.Length) { + return false; + } + for (int i = 0; i < suffix.Length; i++) { + if (line [line.Length - suffix.Length + i] != suffix [i]) { + return false; + } + } + return true; + } + } } diff --git a/src/Microsoft.Android.Build.Tasks/Utilities/NativeAotTypeMapReader.cs b/src/Microsoft.Android.Build.Tasks/Utilities/NativeAotTypeMapReader.cs index 23e80c5914b..b87b8bad951 100644 --- a/src/Microsoft.Android.Build.Tasks/Utilities/NativeAotTypeMapReader.cs +++ b/src/Microsoft.Android.Build.Tasks/Utilities/NativeAotTypeMapReader.cs @@ -11,7 +11,17 @@ sealed class NativeAotTypeMapReader (byte [] data) { static readonly UTF8Encoding Utf8 = new UTF8Encoding (false, true); - public void ReadKeys (ISet keys) + public HashSet ReadGroupTypeIndices () + { + var indices = new HashSet (); + foreach (int groupOffset in ReadHashtable (0)) { + int offset = groupOffset; + indices.Add (ReadUnsigned (ref offset)); + } + return indices; + } + + public void ReadKeys (ISet keys, ISet javaGroups) { var groups = new HashSet (); var entries = new HashSet (); @@ -20,7 +30,10 @@ public void ReadKeys (ISet keys) continue; } int offset = groupOffset; - ReadUnsigned (ref offset); // Group type's common-fixup index. + uint groupTypeIndex = ReadUnsigned (ref offset); + if (!javaGroups.Contains (groupTypeIndex)) { + continue; + } if (ReadUnsigned (ref offset) != 1) { throw new BadImageFormatException ("The NativeAOT object contains an invalid external type map."); } diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromNativeAotObjectTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromNativeAotObjectTests.cs index b9c976ffca5..947a6dd7eec 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromNativeAotObjectTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ExtractTypeMapKeysFromNativeAotObjectTests.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Text; +using System.Text.Json; using System.Text.Json.Nodes; using System.Threading.Tasks; using Microsoft.Android.Tasks; @@ -47,7 +48,7 @@ public void CapturedIlcArm64BlobContainsOnlyRetainedCanonicalKeys () } [Fact] - public void ExtractsOnlySymbolPayloadAndUnionsEveryGroupAndObject () + public void ExtractsOnlySymbolPayloadAndUnionsEveryJavaGroupAndObject () { string arm64 = WriteObject ("arm64", NativeAotObjectTestFixture.CreateGroups ( ["test/Zebra", "test/Outer$Inner", "test/Alias[0]", "test/\u00e9clair"], @@ -134,6 +135,175 @@ public void PrimitiveArraysContributeNoClasses () Assert.Empty (File.ReadAllBytes (task.OutputFile)); } + [Fact] + public void SelectsJavaGroupsByFixupsRatherThanKeyAppearance () + { + string path = WriteObject ("mixed-universes", NativeAotObjectTestFixture.CreateGroups ( + ["System.Collections.Generic.IDictionary`2[System.Char,System.Int32]", "foreign/LooksLikeJava"], + ["test/Shared", "test/Alias[0]"], + ["test/PerAssembly", "test/Alias[1]"])); + var (task, engine) = CreateTask (path); + task.UseGroupMetadata = true; + task.RelocationOutput = AddGroupMetadata (path, + "_ZTV43Mono_Android_Android_Runtime_JavaDictionary", + "_ZTV29Mono_Android_Java_Lang_Object", + "_ZTV37_Mono_Android_TypeMap___TypeMapAnchor"); + + Assert.True (task.Execute ()); + Assert.Empty (engine.Errors); + Assert.Equal ("test/Alias\ntest/PerAssembly\ntest/Shared\n", File.ReadAllText (task.OutputFile)); + Assert.Equal (".rela.rodata", task.RelocationSection); + Assert.Equal (12, task.RelocationEnd - task.RelocationStart); + } + + [Theory] + [InlineData ("aarch64", "R_AARCH64_PREL32", "SHT_RELA", ".rela.rodata")] + [InlineData ("arm", "R_ARM_REL32", "SHT_REL", ".rel.rodata")] + [InlineData ("x86_64", "R_X86_64_PC32", "SHT_RELA", ".rela.rodata")] + [InlineData ("i386", "R_386_PC32", "SHT_REL", ".rel.rodata")] + public void ResolvesGroupSlotsUsingTargetRelocationKind (string arch, string kind, string sectionType, string sectionName) + { + string path = WriteObject ("abi-group", NativeAotObjectTestFixture.CreateBlob ("test/Live")); + Summary (path) ["Arch"] = arch; + var (task, engine) = CreateTask (path); + task.UseGroupMetadata = true; + task.RelocationOutput = AddGroupMetadata (path, "_ZTV29Mono_Android_Java_Lang_Object") + .Replace ("R_AARCH64_PREL32", kind, StringComparison.Ordinal); + var relocationSection = RelocationSection (path); + relocationSection ["Name"] = new JsonObject { ["Name"] = sectionName, ["Value"] = 0 }; + relocationSection ["Type"] = new JsonObject { ["Name"] = sectionType, ["Value"] = 0 }; + + Assert.True (task.Execute ()); + Assert.Empty (engine.Errors); + Assert.Equal ("test/Live\n", File.ReadAllText (task.OutputFile)); + Assert.Equal (sectionName, task.RelocationSection); + } + + [Fact] + public void ResolvesCompilationPrefixedMapAndFixupSymbols () + { + string path = WriteObject ("prefixed", NativeAotObjectTestFixture.CreateBlob ("test/Live")); + Symbol (path) ["Name"] = new JsonObject { ["Name"] = "Compilation_123___external_type_map__", ["Value"] = 0 }; + var (task, engine) = CreateTask (path); + task.UseGroupMetadata = true; + task.RelocationOutput = AddGroupMetadata (path, "Compilation_123__ZTV29Mono_Android_Java_Lang_Object"); + + Assert.True (task.Execute ()); + Assert.Empty (engine.Errors); + Assert.Equal ("test/Live\n", File.ReadAllText (task.OutputFile)); + } + + [Fact] + public void MalformedSelectedJavaKeyStillFails () + { + string path = WriteObject ("invalid-java-group", NativeAotObjectTestFixture.CreateGroups ( + ["System.Collections.Generic.IDictionary`2[System.Char,System.Int32]"], + ["test/Invalid[abc]"])); + var (task, engine) = CreateTask (path); + task.UseGroupMetadata = true; + task.RelocationOutput = AddGroupMetadata (path, + "_ZTV43Mono_Android_Android_Runtime_JavaDictionary", "_ZTV29Mono_Android_Java_Lang_Object"); + + AssertFailure (task, engine); + } + + [Theory] + [InlineData ("_ZTV43Mono_Android_Android_Runtime_JavaDictionary")] + [InlineData ("_ZTV29Mono_Android_Java_Lang_ObjectExtra")] + [InlineData ("_ZTV30ThirdParty_TypeMap___TypeMapAnchor")] + [InlineData ("_ZTV30_Other_TypeMap___TypeMapAnchorExtra")] + public void MissingRecognizedJavaGroupIsNotAnEmptySuccess (string groupSymbol) + { + string path = WriteObject ("foreign-only", NativeAotObjectTestFixture.CreateBlob ("test/LooksLikeJava")); + var (task, engine) = CreateTask (path); + task.UseGroupMetadata = true; + task.RelocationOutput = AddGroupMetadata (path, groupSymbol); + + AssertFailure (task, engine); + } + + [Theory] + [InlineData ("missing-fixups")] + [InlineData ("duplicate-fixups")] + [InlineData ("bad-fixup-size")] + [InlineData ("missing-relocation-section")] + [InlineData ("ambiguous-relocation-section")] + [InlineData ("wrong-target-section")] + [InlineData ("missing-relocation")] + [InlineData ("duplicate-relocation")] + [InlineData ("wrong-relocation-kind")] + [InlineData ("nonzero-addend")] + public void InvalidGroupMetadataFails (string defect) + { + string path = WriteObject ("invalid-group-metadata", NativeAotObjectTestFixture.CreateBlob ("test/Live")); + var (task, engine) = CreateTask (path); + task.UseGroupMetadata = true; + task.RelocationOutput = AddGroupMetadata (path, "_ZTV29Mono_Android_Java_Lang_Object"); + var symbols = Array (Document (path) ["Symbols"]); + var sections = Array (Document (path) ["Sections"]); + switch (defect) { + case "missing-fixups": + symbols.RemoveAt (symbols.Count - 1); + break; + case "duplicate-fixups": + symbols.Add (symbols [symbols.Count - 1]?.DeepClone ()); + break; + case "bad-fixup-size": + Object (Object (symbols [symbols.Count - 1]) ["Symbol"]) ["Size"] = 3; + break; + case "missing-relocation-section": + sections.RemoveAt (sections.Count - 1); + break; + case "ambiguous-relocation-section": + var duplicate = Object (sections [sections.Count - 1]?.DeepClone ()); + Object (duplicate ["Section"]) ["Index"] = 4; + sections.Add (duplicate); + break; + case "wrong-target-section": + RelocationSection (path) ["Info"] = 2; + break; + case "missing-relocation": + task.RelocationOutput = ""; + break; + case "duplicate-relocation": + task.RelocationOutput += task.RelocationOutput; + break; + case "wrong-relocation-kind": + task.RelocationOutput = task.RelocationOutput.Replace ("R_AARCH64_PREL32", "R_AARCH64_ABS64", StringComparison.Ordinal); + break; + case "nonzero-addend": + task.RelocationOutput = task.RelocationOutput.TrimEnd () + "+0x4\n"; + break; + } + AssertFailure (task, engine); + } + + [Fact] + public void OutOfRangeGroupFixupFails () + { + byte [] group = NativeAotObjectTestFixture.CreateGroup ( + NativeAotObjectTestFixture.CreateTable ([NativeAotObjectTestFixture.CreateKey ("test/Live")]), typeIndex: 2); + string path = WriteObject ("bad-group-index", NativeAotObjectTestFixture.CreateTable ([group])); + var (task, engine) = CreateTask (path); + task.UseGroupMetadata = true; + task.RelocationOutput = AddGroupMetadata (path, "_ZTV29Mono_Android_Java_Lang_Object"); + + AssertFailure (task, engine); + Assert.Null (task.RelocationSection); + } + + [Fact] + public void EmptyOuterTableDoesNotRequireGroupRelocations () + { + var (task, engine) = CreateTask (WriteObject ("empty-groups", NativeAotObjectTestFixture.CreateGroups ())); + task.UseGroupMetadata = true; + + Assert.True (task.Execute ()); + Assert.Empty (engine.Errors); + Assert.Empty (File.ReadAllBytes (task.OutputFile)); + Assert.Null (task.RelocationSection); + } + [Theory] [InlineData (true)] [InlineData (false)] @@ -567,13 +737,65 @@ string WriteObject (string name, byte [] blob) JsonObject Summary (string path) => Object (Document (path) ["FileSummary"]); JsonObject Section (string path) => Object (Object (Array (Document (path) ["Sections"]) [1]) ["Section"]); JsonObject Symbol (string path) => Object (Object (Array (Document (path) ["Symbols"]) [1]) ["Symbol"]); + JsonObject RelocationSection (string path) => Object (Object (Array (Document (path) ["Sections"]) [2]) ["Section"]); + + string AddGroupMetadata (string path, params string [] groupSymbols) + { + var symbol = Symbol (path); + long size = symbol ["Size"]?.GetValue () ?? throw new InvalidOperationException (); + string mapName = Object (symbol ["Name"]) ["Name"]?.GetValue () ?? throw new InvalidOperationException (); + string prefix = mapName.Substring (0, mapName.Length - "__external_type_map__".Length); + long value = SymbolOffset + size + 16; + Array (Document (path) ["Symbols"]).Add (new JsonObject { + ["Symbol"] = new JsonObject { + ["Name"] = new JsonObject { ["Name"] = prefix + "__external_CommonFixupsTable_references", ["Value"] = 0 }, + ["Value"] = value, + ["Size"] = groupSymbols.Length * 4, + ["Section"] = new JsonObject { ["Name"] = ".rodata", ["Value"] = 1 }, + }, + }); + Array (Document (path) ["Sections"]).Add (new JsonObject { + ["Section"] = new JsonObject { + ["Index"] = 3, + ["Name"] = new JsonObject { ["Name"] = ".rela.rodata", ["Value"] = 0 }, + ["Type"] = new JsonObject { ["Name"] = "SHT_RELA", ["Value"] = 4 }, + ["Info"] = 1, + }, + }); + var relocations = new StringBuilder ("RELOCATION RECORDS FOR [.rodata]:\nOFFSET TYPE VALUE\n"); + for (int i = 0; i < groupSymbols.Length; i++) { + relocations.Append ($"{value + i * 4:x16} R_AARCH64_PREL32 {groupSymbols [i]}\n"); + } + return relocations.ToString (); + } + static JsonObject Object (JsonNode? node) => node as JsonObject ?? throw new InvalidOperationException ("Expected an object fixture."); static JsonArray Array (JsonNode? node) => node as JsonArray ?? throw new InvalidOperationException ("Expected an array fixture."); sealed class MetadataTask (Func reader) : ExtractTypeMapKeysFromNativeAotObject { public Func MetadataReader { get; set; } = reader; + public bool UseGroupMetadata { get; set; } + public string RelocationOutput { get; set; } = ""; + public string? RelocationSection { get; private set; } + public long RelocationStart { get; private set; } + public long RelocationEnd { get; private set; } protected override Task ReadObjectMetadataAsync (string objectFile) => Task.FromResult (MetadataReader (objectFile)); + + // Parser fixtures model Java groups; separate relocation tests exercise group selection. + protected override Task> GetJavaTypeMapGroupsAsync ( + string objectFile, JsonElement fileMetadata, string mapSymbol, IReadOnlyCollection groupIndices, long fileLength) => + UseGroupMetadata + ? base.GetJavaTypeMapGroupsAsync (objectFile, fileMetadata, mapSymbol, groupIndices, fileLength) + : Task.FromResult (new HashSet (groupIndices)); + + protected override Task ReadObjectRelocationsAsync (string objectFile, string section, long start, long end) + { + RelocationSection = section; + RelocationStart = start; + RelocationEnd = end; + return Task.FromResult (RelocationOutput); + } } } diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/NativeAotObjectTestFixture.Tool.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/NativeAotObjectTestFixture.Tool.cs index 0a2ea93bfe0..92a8243f472 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/NativeAotObjectTestFixture.Tool.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/NativeAotObjectTestFixture.Tool.cs @@ -11,6 +11,10 @@ namespace Microsoft.Android.Sdk.TrimmableTypeMap.Tests; static partial class NativeAotObjectTestFixture { public static string WriteObject (string directory, string name, string llvmReadObjPath, params string [] keys) + => WriteObjectGroups (directory, name, llvmReadObjPath, ("_ZTV29Mono_Android_Java_Lang_Object", keys)); + + public static string WriteObjectGroups ( + string directory, string name, string llvmReadObjPath, params (string Symbol, string [] Keys) [] groups) { string? toolDirectory = Path.GetDirectoryName (llvmReadObjPath); if (string.IsNullOrEmpty (toolDirectory)) { @@ -20,7 +24,7 @@ public static string WriteObject (string directory, string name, string llvmRead Directory.CreateDirectory (directory); string objectPath = Path.ChangeExtension (Path.Combine (directory, name), ".o"); string sourcePath = Path.ChangeExtension (objectPath, ".s"); - byte [] blob = CreateBlob (keys); + byte [] blob = CreateGroups (groups.Select (group => group.Keys).ToArray ()); var source = new StringBuilder (""" .section .rodata,"a",%progbits .globl __external_type_map__ @@ -35,6 +39,27 @@ .hidden __external_type_map__ source.Append ('\n'); } source.Append (".size __external_type_map__, . - __external_type_map__\n"); + source.Append (""" + .balign 4 + .globl __external_CommonFixupsTable_references + .type __external_CommonFixupsTable_references,%object + __external_CommonFixupsTable_references: + + """); + foreach (var group in groups) { + source.Append (".long ").Append (group.Symbol).Append (" - .\n"); + } + source.Append (""" + .size __external_CommonFixupsTable_references, . - __external_CommonFixupsTable_references + .section .data,"aw",%progbits + + """); + foreach (string symbol in groups.Select (group => group.Symbol).Distinct (StringComparer.Ordinal)) { + source.Append (".globl ").Append (symbol).Append ('\n'); + source.Append (".type ").Append (symbol).Append (",%object\n"); + source.Append (symbol).Append (":\n.byte 0\n"); + source.Append (".size ").Append (symbol).Append (", 1\n"); + } File.WriteAllText (sourcePath, source.ToString (), new UTF8Encoding (false)); using var stdout = new StringWriter (CultureInfo.InvariantCulture); From 96555e699d89a67fec1ba266fbb7e8bb193d9fe3 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 18 Sep 2026 10:29:08 +0200 Subject: [PATCH 23/25] Replace NativeAOT DGML integration with final-object typemap extraction Read each RID's actual NativeObject with explicit NDK LLVM tools, select Java groups structurally, and preserve no-NDK default builds through the private opt-in. Remove automatic graphs and retire DGML diagnostics. Reconcile scoped CoreCLR member rules and normalized incremental inputs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../skills/tests/references/test-catalog.md | 7 + Documentation/docs-mobile/messages/index.md | 6 +- Documentation/docs-mobile/messages/xa4319.md | 5 + Documentation/docs-mobile/messages/xa4320.md | 4 + Documentation/docs-mobile/messages/xa4321.md | 5 + Documentation/docs-mobile/messages/xa4327.md | 9 +- Documentation/guides/D8andR8.md | 34 ++-- ...oft.Android.Sdk.AssemblyResolution.targets | 20 ++- ...osoft.Android.Sdk.TypeMap.Proguard.targets | 35 ++-- ...id.Sdk.TypeMap.Trimmable.NativeAOT.targets | 18 +- .../Properties/Resources.Designer.cs | 27 --- .../Properties/Resources.resx | 14 -- .../TrimmableTypeMapBuildTests.cs | 24 ++- .../Xamarin.Android.Common.targets | 1 + .../Generator/TypeMapProguardTests.cs | 168 +++++++++++++----- ....Android.Sdk.TrimmableTypeMap.Tests.csproj | 1 + .../NativeAotObjectIntegrationTests.cs | 31 ++++ 17 files changed, 261 insertions(+), 148 deletions(-) create mode 100644 tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/NativeAotObjectIntegrationTests.cs diff --git a/.github/skills/tests/references/test-catalog.md b/.github/skills/tests/references/test-catalog.md index 583f32cfce6..6cdc6968fc9 100644 --- a/.github/skills/tests/references/test-catalog.md +++ b/.github/skills/tests/references/test-catalog.md @@ -39,6 +39,13 @@ These tests can be run immediately with `dotnet test` on the `.csproj`, even if --- +The NativeAOT object/MSBuild integration cases in the trimmable type map suite +also require the NDK `llvm-readobj` and adjacent `llvm-objdump` and `clang` executables. Pass +`-p:_NativeAotLlvmReadObjPath=/path/to/ndk/toolchains/llvm/prebuilt//bin/llvm-readobj` +(with `.exe` on Windows) to execute those cases; without it, those cases are +reported as skipped. The NativeFormat parser and other typemap unit tests do +not require native tools. + ## Host-Side MSBuild Tests (full-build — requires local SDK) Assembly: `bin/TestDebug/${TFM}/Xamarin.Android.Build.Tests.dll` diff --git a/Documentation/docs-mobile/messages/index.md b/Documentation/docs-mobile/messages/index.md index c869318e78b..05dc8b57f3f 100644 --- a/Documentation/docs-mobile/messages/index.md +++ b/Documentation/docs-mobile/messages/index.md @@ -255,9 +255,9 @@ Either change the value in the AndroidManifest.xml to match the $(SupportedOSPla + [XA4316](xa4316.md): Specified input file '{file}' does not exist. Ignoring. + [XA4317](xa4317.md): Input file '{file}' does not start with ``. Skipping. + [XA4318](xa4318.md): Input file '{file}' could not be read: {message}. Skipping. -+ [XA4319](xa4319.md): No NativeAOT DGML files were provided. -+ [XA4320](xa4320.md): ACW map file '{file}' was not found. -+ [XA4321](xa4321.md): NativeAOT DGML file '{file}' was not found. ++ [XA4319](xa4319.md): Retired: No NativeAOT DGML files were provided. ++ [XA4320](xa4320.md): Retired: ACW map file '{file}' was not found. ++ [XA4321](xa4321.md): Retired: NativeAOT DGML file '{file}' was not found. + [XA4322](xa4322.md): Skipping library ProGuard configuration file '{file}' (from {source}) because it contains the unsupported global option '{option}'. Global ProGuard options are only allowed in application projects. + [XA4323](xa4323.md): Ignoring directory '{directory}' as it does not exist. + [XA4324](xa4324.md): [{arch}] Unable to delete source file '{file}'. diff --git a/Documentation/docs-mobile/messages/xa4319.md b/Documentation/docs-mobile/messages/xa4319.md index 636c8b9dcd8..d5a1bb9e09c 100644 --- a/Documentation/docs-mobile/messages/xa4319.md +++ b/Documentation/docs-mobile/messages/xa4319.md @@ -8,6 +8,11 @@ f1_keywords: # .NET for Android error XA4319 +This diagnostic was retired when NativeAOT typemap extraction moved from DGML +graphs to ILC object files. Current builds do not require dependency graphs +for Java trimming; object extraction failures use [XA4327](xa4327.md). +The following describes the earlier DGML-based pipeline. + ## Example messages ``` diff --git a/Documentation/docs-mobile/messages/xa4320.md b/Documentation/docs-mobile/messages/xa4320.md index cb70fac90bf..0f5aeada47f 100644 --- a/Documentation/docs-mobile/messages/xa4320.md +++ b/Documentation/docs-mobile/messages/xa4320.md @@ -8,6 +8,10 @@ f1_keywords: # .NET for Android error XA4320 +This diagnostic was retired when NativeAOT typemap extraction moved from the +DGML/ACW join to ILC object files. Current object extraction failures use +[XA4327](xa4327.md). The following describes the earlier DGML-based pipeline. + ## Example messages ``` diff --git a/Documentation/docs-mobile/messages/xa4321.md b/Documentation/docs-mobile/messages/xa4321.md index f082e1e7157..a33660ceb3b 100644 --- a/Documentation/docs-mobile/messages/xa4321.md +++ b/Documentation/docs-mobile/messages/xa4321.md @@ -8,6 +8,11 @@ f1_keywords: # .NET for Android error XA4321 +This diagnostic was retired when NativeAOT typemap extraction moved from DGML +graphs to ILC object files. Current builds do not require dependency graphs +for Java trimming; object extraction failures use [XA4327](xa4327.md). +The following describes the earlier DGML-based pipeline. + ## Example messages ``` diff --git a/Documentation/docs-mobile/messages/xa4327.md b/Documentation/docs-mobile/messages/xa4327.md index 4c67601e0f3..403fa484b84 100644 --- a/Documentation/docs-mobile/messages/xa4327.md +++ b/Documentation/docs-mobile/messages/xa4327.md @@ -16,8 +16,8 @@ error XA4327: Could not extract Java type map keys from 'typemaps.arm64-v8a.ll': ## Issue -The build could not read retained Java class names from a NativeAOT dependency -graph and ACW map, a CoreCLR LLVM typemap, or linked trimmable typemap assemblies. +The build could not read retained Java class names from a NativeAOT object, +a CoreCLR LLVM typemap, or linked trimmable typemap assemblies. The input is missing, unreadable, malformed, or uses an unsupported representation. The diagnostic includes the input and the underlying failure. @@ -30,3 +30,8 @@ Fix the reported input or the earlier task that produces it. If the input was generated by .NET for Android, clean and rebuild the application. If the error persists, [report an issue](https://github.com/dotnet/android/issues/new/choose) with a binary build log and the affected typemap input. + +NativeAOT object extraction also requires the Android NDK's `llvm-readobj` +and adjacent `llvm-objdump`. +Install the NDK selected by `GetAndroidDependencies`, or point +`AndroidNdkDirectory` at a complete installation. diff --git a/Documentation/guides/D8andR8.md b/Documentation/guides/D8andR8.md index 5612a33b65f..0f897bc8725 100644 --- a/Documentation/guides/D8andR8.md +++ b/Documentation/guides/D8andR8.md @@ -8,17 +8,18 @@ every class in the ACW map: | Runtime and typemap | Retained-key source | | --- | --- | -| NativeAOT, trimmable | ILC type-metadata nodes joined to the ACW map | +| NativeAOT, trimmable | Retained external typemap records in each RID's final ILC object | | CoreCLR, LLVM IR | The generated LLVM Java-name blob, after `GenerateTypeMappings` | | CoreCLR, trimmable | Surviving `TypeMapAttribute` records in linked typemap assemblies, including empty stubs | Inner builds return their exact source paths to the outer build. Keys are unioned across the requested RIDs/ABIs; stale files from other builds are not -discovered by globbing. NativeAOT uses the scan graph in optimized builds and -the codegen graph when the scanner is disabled. DGML is consumed only by the -retained-keys adapter, not by a separate NativeAOT rule generator. +discovered by globbing. NativeAOT locates external typemap blobs with +`llvm-readobj`, resolves Java group identities using bounded `llvm-objdump` +relocations, reads their object-file byte ranges, and decodes the retained +NativeFormat keys. It does not require an ACW map or generate dependency graphs. -All three adapters and the shared generator are `Microsoft.Android.Tasks` +All three adapters and both ProGuard generators are `Microsoft.Android.Tasks` tasks in `Microsoft.Android.Build.Tasks.dll`. The adapters write `typemap.keys.txt` in the outer intermediate directory. The format is UTF-8 without a BOM, one canonical JNI class name per line, @@ -30,8 +31,10 @@ android/app/Activity example/Outer$Inner ``` -The DLL adapter collapses implementation-specific numeric aliases before -writing this format. A valid zero-byte file means no retained classes; +The assembly and NativeAOT object adapters collapse implementation-specific +numeric aliases before writing this format. Valid JNI array entries contribute +their reference element class; primitive arrays do not contribute a class. +A valid zero-byte file means no retained classes; missing, unreadable, or unsupported inputs fail the build. `GenerateTypeMapProguardConfiguration` knows only this format. It accepts a @@ -68,14 +71,19 @@ application/library ProGuard rules remain separate. The temporary private override `_AndroidEnableTypemapR8Trimming` controls this pipeline. It replaces the old NativeAOT-specific trimming and ProGuard switches. -Leave it unset for the automatic behavior, set it to `true` to enable +Leave it unset for automatic CoreCLR behavior, set it to `true` to enable the pipeline in eligible managed-trimmed CoreCLR/NativeAOT R8 builds, or set it to `false` to use legacy ACW retention without running the new tasks. Disabling -it also avoids automatic NativeAOT DGML generation, but does not suppress -explicit `IlcGenerateDgmlFile` diagnostics. It does not enable R8 or managed -trimming in otherwise ineligible builds. -Automatic NativeAOT selection requires an optimized build; unoptimized builds -require explicit `true` to avoid generating large codegen graphs by default. +it avoids NativeAOT object inspection. Explicit `IlcGenerateDgmlFile` +diagnostics are independent and remain honored. The switch does not enable R8 +or managed trimming in otherwise ineligible builds. + +NativeAOT object inspection currently requires explicit `true` and the Android +NDK's `llvm-readobj` and adjacent `llvm-objdump`, even when using the +workload-provided native linker. The workload's native tools do not yet include them. +`GetAndroidDependencies` includes the NDK for this opt-in configuration. +Leaving the override unset or `false` preserves NativeAOT's no-NDK build path +and keeps all ACW classes. This pipeline disables all obfuscation, including private-member obfuscation, with both `-dontobfuscate` and R8's `--no-minification` option. 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 39d33ed999f..0a0a8b3ac26 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 @@ -72,16 +72,21 @@ _ResolveAssemblies MSBuild target. + + <_AndroidTypeMapLlvmReadObjPath>$(_NdkBinDir)llvm-readobj + <_AndroidTypeMapLlvmReadObjPath Condition=" $([MSBuild]::IsOSPlatform('windows')) ">$(_NdkBinDir)llvm-readobj.exe + <_AndroidTypeMapLlvmObjDumpPath>$(_NdkBinDir)llvm-objdump + <_AndroidTypeMapLlvmObjDumpPath Condition=" $([MSBuild]::IsOSPlatform('windows')) ">$(_NdkBinDir)llvm-objdump.exe + + ILC object and linked metadata must not be replaced by pre-trim or R2R inputs. --> - + Condition=" '$(_AndroidEnableTypemapR8Trimming)' == 'true' and '$(_AndroidRuntime)' == 'NativeAOT' and '$(AndroidTypeMapImplementation)' == 'trimmable' " + AndroidTypeMapNativeObject="$(NativeObject)" + AndroidTypeMapLlvmReadObjPath="$(_AndroidTypeMapLlvmReadObjPath)" + AndroidTypeMapLlvmObjDumpPath="$(_AndroidTypeMapLlvmObjDumpPath)" /> <_AndroidLinkedTypeMapKeySource Include="@(_GeneratedTypeMapAssembliesFromList->'$(IntermediateOutputPath)linked\%(Filename)%(Extension)')" Condition=" '$(_AndroidEnableTypemapR8Trimming)' != 'false' and '$(_AndroidRuntime)' == 'CoreCLR' and '$(AndroidTypeMapImplementation)' == 'trimmable' and '$(PublishTrimmed)' == 'true' " /> diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Proguard.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Proguard.targets index 51c1dd5b730..9ffc55ec1bf 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Proguard.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Proguard.targets @@ -1,6 +1,6 @@ - + @@ -10,9 +10,11 @@ <_CompileToDalvikDependsOnTargets>$(_CompileToDalvikDependsOnTargets);_AndroidGenerateTypeMapProguardConfiguration + + Condition=" '$(_AndroidEnableTypemapR8Trimming)' != 'false' and '$(PublishTrimmed)' == 'true' and '$(AndroidLinkTool)' == 'r8' and '$(_ComputeFilesToPublishForRuntimeIdentifiers)' != 'true' and ('$(_AndroidRuntime)' == 'CoreCLR' or ('$(_AndroidRuntime)' == 'NativeAOT' and '$(AndroidTypeMapImplementation)' == 'trimmable' and '$(_AndroidEnableTypemapR8Trimming)' == 'true')) "> <_ProguardProjectConfiguration>$(IntermediateOutputPath)proguard\proguard_project_references.cfg <_AndroidTypeMapProguardConfiguration>$(_ProguardProjectConfiguration) @@ -35,7 +37,7 @@ @@ -53,26 +55,27 @@ <_AndroidTypeMapKeysFile>$(IntermediateOutputPath)typemap.keys.txt <_AndroidTypeMapKeysInputsFile>$(IntermediateOutputPath)typemap.keys.inputs <_AndroidTypeMapTasksAssemblyFile>$([MSBuild]::NormalizePath('$(MSBuildThisFileDirectory)', '$(_MicrosoftAndroidBuildTasksAssembly)')) - <_AndroidTypeMapKeysKind Condition=" '$(_AndroidRuntime)' == 'NativeAOT' ">dgml + <_AndroidTypeMapKeysKind Condition=" '$(_AndroidRuntime)' == 'NativeAOT' ">native-object <_AndroidTypeMapKeysKind Condition=" '$(_AndroidRuntime)' == 'CoreCLR' and '$(AndroidTypeMapImplementation)' != 'trimmable' ">llvm-ir <_AndroidTypeMapKeysKind Condition=" '$(_AndroidRuntime)' == 'CoreCLR' and '$(AndroidTypeMapImplementation)' == 'trimmable' ">assemblies <_AndroidTypeMapLinkedAssemblies>@(ResolvedFileToPublish->'%(AndroidTypeMapLinkedAssemblies)'->Distinct()) + <_AndroidTypeMapLlvmReadObjPath /> + <_AndroidTypeMapLlvmReadObjPath Condition=" '$(_AndroidTypeMapKeysKind)' == 'native-object' ">@(ResolvedFileToPublish->'%(AndroidTypeMapLlvmReadObjPath)'->Distinct()) + <_AndroidTypeMapLlvmObjDumpPath /> + <_AndroidTypeMapLlvmObjDumpPath Condition=" '$(_AndroidTypeMapKeysKind)' == 'native-object' ">@(ResolvedFileToPublish->'%(AndroidTypeMapLlvmObjDumpPath)'->Distinct()) <_AndroidTypeMapKeySource Remove="@(_AndroidTypeMapKeySource)" /> - <_AndroidTypeMapDgmlSource Remove="@(_AndroidTypeMapDgmlSource)" /> - <_AndroidTypeMapDgmlSource Include="@(ResolvedFileToPublish->'%(AndroidTypeMapDgmlFile)'->Distinct())" - Condition=" '$(_AndroidTypeMapKeysKind)' == 'dgml' " /> - <_AndroidTypeMapKeySource Include="@(_AndroidTypeMapDgmlSource);$(_AcwMapFile)" - Condition=" '$(_AndroidTypeMapKeysKind)' == 'dgml' " /> + <_AndroidTypeMapKeySource Include="@(ResolvedFileToPublish->'%(AndroidTypeMapNativeObject)'->Distinct())" + Condition=" '$(_AndroidTypeMapKeysKind)' == 'native-object' " /> <_AndroidTypeMapKeySource Include="@(_TypeMapAssemblySource->Distinct())" Condition=" '$(_AndroidTypeMapKeysKind)' == 'llvm-ir' " /> <_AndroidTypeMapKeySource Include="$(_AndroidTypeMapLinkedAssemblies)" Condition=" '$(_AndroidTypeMapKeysKind)' == 'assemblies' " /> <_AndroidTypeMapKeysState Remove="@(_AndroidTypeMapKeysState)" /> - <_AndroidTypeMapKeysState Include="Runtime=$(_AndroidRuntime);Representation=$(_AndroidTypeMapKeysKind);Optimize=$(Optimize);RuntimeIdentifier=$(RuntimeIdentifier);RuntimeIdentifiers=$(RuntimeIdentifiers);TypemapPolicy=$(_AndroidUseTypeMapProguardConfiguration);Enabled=$(_AndroidEnableTypemapR8Trimming)" /> + <_AndroidTypeMapKeysState Include="Runtime=$(_AndroidRuntime);Representation=$(_AndroidTypeMapKeysKind);Optimize=$(Optimize);RuntimeIdentifier=$(RuntimeIdentifier);RuntimeIdentifiers=$(RuntimeIdentifiers);TypemapPolicy=$(_AndroidUseTypeMapProguardConfiguration);Enabled=$(_AndroidEnableTypemapR8Trimming);ReadObj=$(_AndroidTypeMapLlvmReadObjPath);ObjDump=$(_AndroidTypeMapLlvmObjDumpPath)" /> <_AndroidTypeMapKeysState Include="@(_AndroidTypeMapKeySource->'%(FullPath)')" /> + <_AndroidBuildRuntimeIdentifiersInParallel - Condition=" (('$(_AndroidEnableTypemapR8Trimming)' != 'false' and ('$(Optimize)' == 'true' or '$(_AndroidEnableTypemapR8Trimming)' == 'true')) or '$(IlcGenerateDgmlFile)' == 'true') and '$(_AndroidBuildRuntimeIdentifiersInParallel)' == '' ">false + Condition=" '$(IlcGenerateDgmlFile)' == 'true' and '$(_AndroidBuildRuntimeIdentifiersInParallel)' == '' ">false <_TrimmableRuntimeProviderJavaName Condition=" '$(_TrimmableRuntimeProviderJavaName)' == '' ">net.dot.jni.nativeaot.NativeAotRuntimeProvider r8 d8 @@ -47,16 +45,6 @@ <_TrimmableTypeMapUnmanagedEntryPointAssemblyNames Remove="@(_TrimmableTypeMapFrameworkIlcAssemblyNames)" /> - - - diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs index 0da0ec3a3b4..20cffe34eb9 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs @@ -1941,33 +1941,6 @@ public static string XA4318 { } } - /// - /// Looks up a localized string similar to No NativeAOT DGML files were provided.. - /// - public static string XA4319 { - get { - return ResourceManager.GetString("XA4319", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to ACW map file '{0}' was not found.. - /// - public static string XA4320 { - get { - return ResourceManager.GetString("XA4320", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to NativeAOT DGML file '{0}' was not found.. - /// - public static string XA4321 { - get { - return ResourceManager.GetString("XA4321", resourceCulture); - } - } - /// /// Looks up a localized string similar to Skipping library ProGuard configuration file '{1}' (from {2}) because it contains the unsupported global option '{0}'. Global ProGuard options are only allowed in application projects.. /// diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx index 1a109688e4c..11f002a791e 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx @@ -853,20 +853,6 @@ Remove the '{0}' reference from your project and add the '{1}' NuGet package ins {0} - The path to the file. {1} - The exception message. - - - No NativeAOT DGML files were provided. - The following are literal names and should not be translated: NativeAOT, DGML - - - ACW map file '{0}' was not found. - The following are literal names and should not be translated: ACW -{0} - The path to the ACW map file. - - - NativeAOT DGML file '{0}' was not found. - The following are literal names and should not be translated: NativeAOT, DGML -{0} - The path to the NativeAOT DGML file. Skipping library ProGuard configuration file '{1}' (from {2}) because it contains the unsupported global option '{0}'. Global ProGuard options are only allowed in application projects. diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs index 240f4ed7cd7..e7865eb27c5 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs @@ -34,6 +34,10 @@ public void RetainedTypeMapClassRulesDriveR8 (AndroidRuntime runtime, string imp proj.SetProperty ("AndroidTypeMapImplementation", implementation); proj.SetProperty ("TrimMode", "full"); proj.SetProperty ("AndroidR8ObfuscationMode", "private-members"); + proj.SetProperty ("_AndroidEnableTypemapR8Trimming", "true"); + if (runtime == AndroidRuntime.NativeAOT) { + proj.SetProperty ("_SkipNdkResolution", "false"); + } using var builder = CreateApkBuilder (); Assert.IsTrue (builder.Build (proj)); @@ -1545,6 +1549,10 @@ public void Build_WithTrimmableTypeMap_IncrementalBuild ([Values] bool isRelease proj.MainActivity = proj.DefaultMainActivity; proj.SetRuntime (runtime); proj.SetProperty ("AndroidTypeMapImplementation", "trimmable"); + proj.SetProperty ("_AndroidEnableTypemapR8Trimming", "true"); + if (runtime == AndroidRuntime.NativeAOT) { + proj.SetProperty ("_SkipNdkResolution", "false"); + } bool trimNativeAotJavaCode = isRelease && runtime == AndroidRuntime.NativeAOT; using var builder = CreateApkBuilder (); @@ -1559,15 +1567,17 @@ public void Build_WithTrimmableTypeMap_IncrementalBuild ([Values] bool isRelease var typemapFingerprintContent = File.ReadAllText (typemapFingerprints); var typemapWriteTimes = typemapDlls.ToDictionary (path => path, File.GetLastWriteTimeUtc); - string scanDgml = ""; - DateTime scanDgmlTimestamp = default; + string nativeObject = ""; + DateTime nativeObjectTimestamp = default; if (trimNativeAotJavaCode) { var ridIntermediateDir = builder.Output.GetIntermediaryPath ("android-arm64"); - scanDgml = Path.Combine (ridIntermediateDir, "native", $"{proj.ProjectName}.scan.dgml.xml"); + nativeObject = Path.Combine (ridIntermediateDir, "native", $"{proj.ProjectName}.o"); + var scanDgml = Path.Combine (ridIntermediateDir, "native", $"{proj.ProjectName}.scan.dgml.xml"); var codegenDgml = Path.Combine (ridIntermediateDir, "native", $"{proj.ProjectName}.codegen.dgml.xml"); - FileAssert.Exists (scanDgml); - FileAssert.DoesNotExist (codegenDgml, "Optimized builds should emit only the scan DGML needed for Java trimming."); - scanDgmlTimestamp = File.GetLastWriteTimeUtc (scanDgml); + FileAssert.Exists (nativeObject); + FileAssert.DoesNotExist (scanDgml, "Typemap extraction should not request a scan DGML."); + FileAssert.DoesNotExist (codegenDgml, "Typemap extraction should not request a codegen DGML."); + nativeObjectTimestamp = File.GetLastWriteTimeUtc (nativeObject); } Assert.IsTrue (builder.Build (proj), "Second build should have succeeded."); @@ -1578,7 +1588,7 @@ public void Build_WithTrimmableTypeMap_IncrementalBuild ([Values] bool isRelease if (trimNativeAotJavaCode) { builder.Output.AssertTargetIsSkipped ("_AndroidExtractTypeMapKeys"); builder.Output.AssertTargetIsSkipped ("_AndroidGenerateTypeMapProguardConfiguration"); - Assert.AreEqual (scanDgmlTimestamp, File.GetLastWriteTimeUtc (scanDgml), "No-op builds should not rewrite the scan DGML."); + Assert.AreEqual (nativeObjectTimestamp, File.GetLastWriteTimeUtc (nativeObject), "No-op builds should not rewrite the ILC object."); } if (isRelease && runtime == AndroidRuntime.CoreCLR) { builder.Output.AssertTargetIsSkipped ("_RemoveRegisterAttributeCoreClr"); diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets index 4300eaad120..d386927ac0a 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets @@ -3055,6 +3055,7 @@ because xbuild doesn't support framework reference assemblies. <_ProjectAndroidManifest>$(ProjectDir)$(AndroidManifest) <_NdkRequired Condition="'$(EnableLLVM)' == 'True'">true <_NdkRequired Condition="'$(PublishAot)' == 'true' and '$(_AndroidUseWorkloadNativeLinker)' != 'true'">true + <_NdkRequired Condition=" '$(_AndroidRuntime)' == 'NativeAOT' and '$(AndroidTypeMapImplementation)' == 'trimmable' and '$(_AndroidEnableTypemapR8Trimming)' == 'true' and '$(PublishTrimmed)' == 'true' and '$(AndroidLinkTool)' == 'r8' ">true <_NdkRequired Condition="'$(_NdkRequired)' == ''">false diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs index bb89bfdf3ab..2fc5fc1a63c 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics; using System.IO; +using System.Linq; using System.Text; using System.Xml.Linq; using Microsoft.Build.Framework; @@ -165,44 +166,60 @@ public void RelativeTaskAssemblyPathDoesNotInvalidateIncrementalOutputs () Assert.Equal (membersTime, File.GetLastWriteTimeUtc (members)); } - [Fact] - public void DgmlTargetUnionsRequestedGraphsAndHonorsDisabledTrimming () + [NativeAotObjectFact] + public void NativeObjectTargetUnionsRidsAndHonorsDisabledTrimming () { - Write ("first.dgml", """"""); - Write ("second.dgml", """"""); + var first = WriteNativeObject ("first", "test/Live", "test/Outer$Inner[0]"); + var second = WriteNativeObject ("second", "test/Second", "test/Outer$Inner[1]"); Write ("acw-map.txt", "App.Live, App;test.Live\nApp.Second, App;test.Second\nApp.Dead, App;test.Dead\n"); var project = CreateProject ("NativeAOT", "trimmable", - new XElement ("ResolvedFileToPublish", new XAttribute ("Include", "first.so"), - new XAttribute ("AndroidTypeMapDgmlFile", "$(MSBuildProjectDirectory)/first.dgml")), - new XElement ("ResolvedFileToPublish", new XAttribute ("Include", "second.so"), - new XAttribute ("AndroidTypeMapDgmlFile", "$(MSBuildProjectDirectory)/second.dgml"))); - Build (project); + NativeObjectItem ("first.so", first), + NativeObjectItem ("second.so", second)); + Build (project, "-p:_AndroidEnableTypemapR8Trimming=true"); var rules = Path.Combine (directory, "obj", "proguard", "proguard_project_references.cfg"); - Assert.Equal ("-keep class test.Live\n-keep class test.Second\n", File.ReadAllText (rules)); + Assert.Equal ("-keep class test.Live\n-keep class test.Outer$Inner\n-keep class test.Second\n", File.ReadAllText (rules)); Assert.False (File.Exists (Path.Combine (directory, "obj", "proguard", "proguard_typemap_members.cfg"))); Build (project, "-p:_AndroidEnableTypemapR8Trimming=false"); Assert.Contains ("legacy ACW configuration", File.ReadAllText (rules)); - File.Delete (Path.Combine (directory, "second.dgml")); - var output = Build (project, expectSuccess: false); - Assert.Contains ("XA4321", output); + File.Delete (second); + var output = Build (project, false, "-p:_AndroidEnableTypemapR8Trimming=true"); + Assert.Contains ("XA4327", output); } - [Fact] + [NativeAotObjectFact] + public void NativeObjectTargetSelectsJavaGroupsWithoutGuessingFromKeys () + { + var nativeObject = NativeAotObjectTestFixture.WriteObjectGroups ( + directory, "groups", NativeAotObjectIntegrationTools.LlvmReadObjPath, + ("_ZTV43Mono_Android_Android_Runtime_JavaDictionary", + ["System.Collections.Generic.IDictionary`2[System.Char,System.Int32]", "foreign/LooksLikeJava"]), + ("_ZTV29Mono_Android_Java_Lang_Object", + ["test/Live", "[Ljava/lang/Object;", "[I"])); + var project = CreateProject ("NativeAOT", "trimmable", NativeObjectItem ("app.so", nativeObject)); + Build (project, "-p:_AndroidEnableTypemapR8Trimming=true"); + var rules = Path.Combine (directory, "obj", "proguard", "proguard_project_references.cfg"); + Assert.Equal ("-keep class java.lang.Object\n-keep class test.Live\n", File.ReadAllText (rules)); + + NativeAotObjectTestFixture.WriteObjectGroups ( + directory, "groups", NativeAotObjectIntegrationTools.LlvmReadObjPath, + ("_ZTV29Mono_Android_Java_Lang_Object", ["test/*"])); + Assert.Contains ("XA4327", Build (project, false, "-p:_AndroidEnableTypemapR8Trimming=true")); + } + + [NativeAotObjectFact] public void RuntimeSwitchCannotReuseAnotherRepresentation () { Write ("first.ll", "@java_type_names = dso_local local_unnamed_addr constant [10 x i8] c\"test/Llvm\\00\", align 1\n"); - Write ("app.dgml", """"""); - Write ("acw-map.txt", "App.Live, App;test.Native\n"); + var nativeObject = WriteNativeObject ("app", "test/Native"); var project = CreateProject ("CoreCLR", "llvm-ir", new XElement ("_TypeMapAssemblySource", new XAttribute ("Include", "$(MSBuildProjectDirectory)/first.ll")), - new XElement ("ResolvedFileToPublish", new XAttribute ("Include", "app.so"), - new XAttribute ("AndroidTypeMapDgmlFile", "$(MSBuildProjectDirectory)/app.dgml"))); + NativeObjectItem ("app.so", nativeObject)); var rules = Path.Combine (directory, "obj", "proguard", "proguard_project_references.cfg"); var members = Path.Combine (directory, "obj", "proguard", "proguard_typemap_members.cfg"); Build (project); Assert.Equal ("-keep class test.Llvm\n", File.ReadAllText (rules)); Assert.Contains ("Members=" + members, File.ReadAllText (Path.Combine (directory, "writes.txt"))); - Build (project, "-p:_AndroidRuntime=NativeAOT", "-p:AndroidTypeMapImplementation=trimmable"); + Build (project, "-p:_AndroidRuntime=NativeAOT", "-p:AndroidTypeMapImplementation=trimmable", "-p:_AndroidEnableTypemapR8Trimming=true"); Assert.Equal ("-keep class test.Native\n", File.ReadAllText (rules)); Assert.DoesNotContain ("Members=" + members, File.ReadAllText (Path.Combine (directory, "writes.txt"))); Build (project); @@ -241,18 +258,17 @@ public void DisabledPipelineNeedsNoTypemapInputsOrModernTaskAssembly (string run } } - [Theory] + [NativeAotObjectTheory] [InlineData ("CoreCLR", "llvm-ir")] [InlineData ("NativeAOT", "trimmable")] public void EnablingDisablingAndUnsettingCannotReuseLegacyRules (string runtime, string representation) { Write ("first.ll", "@java_type_names = dso_local local_unnamed_addr constant [10 x i8] c\"test/Live\\00\", align 1\n"); - Write ("app.dgml", """"""); + var nativeObject = WriteNativeObject ("app", "test/Live"); Write ("acw-map.txt", "App.Live, App;test.Live\n"); var project = CreateProject (runtime, representation, new XElement ("_TypeMapAssemblySource", new XAttribute ("Include", "$(MSBuildProjectDirectory)/first.ll")), - new XElement ("ResolvedFileToPublish", new XAttribute ("Include", "app.so"), - new XAttribute ("AndroidTypeMapDgmlFile", "$(MSBuildProjectDirectory)/app.dgml"))); + NativeObjectItem ("app.so", nativeObject)); var keys = Path.Combine (directory, "obj", "typemap.keys.txt"); var rules = Path.Combine (directory, "obj", "proguard", "proguard_project_references.cfg"); foreach (var enabled in new [] { "true", "" }) { @@ -267,15 +283,20 @@ public void EnablingDisablingAndUnsettingCannotReuseLegacyRules (string runtime, } Assert.DoesNotContain ("-keep class test.Live", File.ReadAllText (rules)); Build (project, "-p:_AndroidEnableTypemapR8Trimming=" + enabled); - Assert.Equal ("-keep class test.Live\n", File.ReadAllText (rules)); - Assert.Contains ("UseTypeMap=true", File.ReadAllText (Path.Combine (directory, "writes.txt"))); + if (runtime == "NativeAOT" && enabled == "") { + Assert.Contains ("legacy ACW configuration", File.ReadAllText (rules)); + Assert.DoesNotContain ("UseTypeMap=true", File.ReadAllText (Path.Combine (directory, "writes.txt"))); + } else { + Assert.Equal ("-keep class test.Live\n", File.ReadAllText (rules)); + Assert.Contains ("UseTypeMap=true", File.ReadAllText (Path.Combine (directory, "writes.txt"))); + } } } [Theory] [InlineData ("false", "false", false)] [InlineData ("false", "true", true)] - [InlineData ("true", "false", true)] + [InlineData ("true", "false", false)] public void DisablingAutomaticDgmlPreservesExplicitDiagnostics (string enabled, string diagnostics, bool serialBuild) { var project = CreateProject ("NativeAOT", "trimmable"); @@ -292,28 +313,66 @@ public void DisablingAutomaticDgmlPreservesExplicitDiagnostics (string enabled, document.Save (project); Build (project, "-p:_AndroidEnableTypemapR8Trimming=" + enabled, "-p:IlcGenerateDgmlFile=" + diagnostics, "-p:Optimize=true"); var output = File.ReadAllText (Path.Combine (directory, "ilc.txt")); - Assert.Equal (enabled == "true" && diagnostics == "false", output.Contains ("--scandgmllog:", StringComparison.Ordinal)); + Assert.DoesNotContain ("--scandgmllog:", output); + Assert.DoesNotContain ("--dgmllog:", output); Assert.Equal (serialBuild, output.Contains ("Parallel=false", StringComparison.Ordinal)); Assert.Contains ("Diagnostics=" + diagnostics, output); } - [Fact] - public void UnoptimizedNativeAotRequiresExplicitPipelineOptIn () + [NativeAotObjectFact] + public void UnoptimizedNativeAotReadsObjectWithoutGraphs () { - Write ("app.dgml", """"""); - Write ("acw-map.txt", "App.Live, App;test.Live\nApp.Dead, App;test.Dead\n"); + var nativeObject = WriteNativeObject ("app", "test/Live"); var project = CreateProject ("NativeAOT", "trimmable", - new XElement ("ResolvedFileToPublish", new XAttribute ("Include", "app.so"), - new XAttribute ("AndroidTypeMapDgmlFile", "$(MSBuildProjectDirectory)/app.dgml"))); + NativeObjectItem ("app.so", nativeObject)); var keys = Path.Combine (directory, "obj", "typemap.keys.txt"); var rules = Path.Combine (directory, "obj", "proguard", "proguard_project_references.cfg"); - Build (project, "-p:Optimize=false"); - Assert.False (File.Exists (keys)); - Assert.Contains ("legacy ACW configuration", File.ReadAllText (rules)); Build (project, "-p:Optimize=false", "-p:_AndroidEnableTypemapR8Trimming=true"); + Assert.Equal ("test/Live\n", File.ReadAllText (keys)); Assert.Equal ("-keep class test.Live\n", File.ReadAllText (rules)); - Build (project, "-p:Optimize=false"); - Assert.Contains ("legacy ACW configuration", File.ReadAllText (rules)); + } + + [Fact] + public void NativeAotWithoutOptInDoesNotRequireObjectTools () + { + Write ("acw-map.txt", "App.Live, App;test.Live\n"); + var project = CreateProject ("NativeAOT", "trimmable"); + Build (project, "-p:_MicrosoftAndroidBuildTasksAssembly=missing.dll"); + Assert.False (File.Exists (Path.Combine (directory, "obj", "typemap.keys.txt"))); + Assert.DoesNotContain ("UseTypeMap=true", File.ReadAllText (Path.Combine (directory, "writes.txt"))); + } + + [Theory] + [InlineData ("NativeAOT", "true", "r8", "true", "true")] + [InlineData ("NativeAOT", "false", "r8", "true", "false")] + [InlineData ("NativeAOT", "", "r8", "true", "false")] + [InlineData ("NativeAOT", "true", "", "true", "false")] + [InlineData ("NativeAOT", "true", "r8", "false", "false")] + [InlineData ("CoreCLR", "true", "r8", "true", "false")] + public void NdkDependencyRequiresNativeObjectOptIn (string runtime, string enabled, string linkTool, string trimmed, string expected) + { + var common = XDocument.Load (Path.Combine (RepositoryDirectory (), "src", "Xamarin.Android.Build.Tasks", "Xamarin.Android.Common.targets")); + XNamespace ns = "http://schemas.microsoft.com/developer/msbuild/2003"; + var dependencyProperties = common.Root?.Elements (ns + "Target") + .Single (target => (string?) target.Attribute ("Name") == "GetAndroidDependencies") + .Element (ns + "PropertyGroup") ?? throw new InvalidOperationException (); + var path = Path.Combine (directory, "dependencies.proj"); + new XDocument (new XElement (ns + "Project", + new XElement (ns + "PropertyGroup", + new XElement (ns + "_AndroidRuntime", runtime), + new XElement (ns + "AndroidTypeMapImplementation", "trimmable"), + new XElement (ns + "_AndroidEnableTypemapR8Trimming", enabled), + new XElement (ns + "_AndroidUseWorkloadNativeLinker", "true"), + new XElement (ns + "PublishAot", "true"), + new XElement (ns + "PublishTrimmed", trimmed), + new XElement (ns + "AndroidLinkTool", linkTool)), + new XElement (ns + "Target", new XAttribute ("Name", "Build"), + new XElement (dependencyProperties), + new XElement (ns + "WriteLinesToFile", new XAttribute ("File", "$(MSBuildProjectDirectory)/ndk-required.txt"), + new XAttribute ("Lines", "$(_NdkRequired)"), new XAttribute ("Overwrite", "true"))))) + .Save (path); + Build (path); + Assert.Equal (expected, File.ReadAllText (Path.Combine (directory, "ndk-required.txt")).Trim ()); } [Fact] @@ -357,8 +416,8 @@ string EmitMap (string name, params string [] keys) } [Theory] - [InlineData ("NativeAOT", "true", "custom-native/App.scan.dgml.xml")] - [InlineData ("NativeAOT", "false", "custom-native/App.codegen.dgml.xml")] + [InlineData ("NativeAOT", "true", "custom-object/retained.o")] + [InlineData ("NativeAOT", "false", "custom-object/retained.o")] [InlineData ("CoreCLR", "true", "obj/linked/_Binding.TypeMap.dll")] public void InnerBuildReturnsExactProducerPaths (string runtime, string optimize, string expected) { @@ -370,6 +429,8 @@ public void InnerBuildReturnsExactProducerPaths (string runtime, string optimize root.Add (new XElement ("PropertyGroup", new XElement ("RuntimeIdentifier", "android-x64"), new XElement ("NativeIntermediateOutputPath", "$(MSBuildProjectDirectory)/custom-native/"), + new XElement ("NativeObject", "$(MSBuildProjectDirectory)/custom-object/retained.o"), + new XElement ("_NdkBinDir", "$(MSBuildProjectDirectory)/custom-ndk-bin/"), new XElement ("TargetName", "App"), new XElement ("Optimize", optimize), new XElement ("_AndroidEnableTypemapR8Trimming", "true"), @@ -381,16 +442,24 @@ public void InnerBuildReturnsExactProducerPaths (string runtime, string optimize new XElement ("ItemGroup", new XElement ("_GeneratedTypeMapAssembliesFromList", new XAttribute ("Include", "generated/_Binding.TypeMap.dll")), new XElement ("ResolvedFileToPublish", new XAttribute ("Include", "R2R/_Microsoft.Android.TypeMaps.dll"))))); - var metadata = runtime == "NativeAOT" ? "AndroidTypeMapDgmlFile" : "AndroidTypeMapLinkedAssemblies"; + var metadata = runtime == "NativeAOT" ? "AndroidTypeMapNativeObject" : "AndroidTypeMapLinkedAssemblies"; root.Add (new XElement ("Target", new XAttribute ("Name", "Build"), new XAttribute ("DependsOnTargets", "_ComputeFilesToPublishForRuntimeIdentifiers"), new XElement ("WriteLinesToFile", new XAttribute ("File", "$(MSBuildProjectDirectory)/producer.txt"), new XAttribute ("Lines", $"@(ResolvedFileToPublish->'%({metadata})')"), + new XAttribute ("Overwrite", "true")), + new XElement ("WriteLinesToFile", new XAttribute ("File", "$(MSBuildProjectDirectory)/readobj.txt"), + new XAttribute ("Lines", "@(ResolvedFileToPublish->'%(AndroidTypeMapLlvmReadObjPath)')"), new XAttribute ("Overwrite", "true")))); document.Save (project); Build (project); Assert.Equal (Path.Combine (directory, expected).Replace ('\\', '/'), File.ReadAllText (Path.Combine (directory, "producer.txt")).Trim ().Replace ('\\', '/')); + if (runtime == "NativeAOT") { + var executable = OperatingSystem.IsWindows () ? "llvm-readobj.exe" : "llvm-readobj"; + Assert.Equal (Path.Combine (directory, "custom-ndk-bin", executable).Replace ('\\', '/'), + File.ReadAllText (Path.Combine (directory, "readobj.txt")).Trim ().Replace ('\\', '/')); + } } [Theory] @@ -471,6 +540,7 @@ string Build (string project, bool expectSuccess, params string [] arguments) RedirectStandardError = true, UseShellExecute = false, }; + start.Environment ["DOTNET_CLI_USE_MSBUILD_SERVER"] = "0"; foreach (var argument in new [] { "msbuild", project, "-t:Build", "-nologo", "-v:minimal", "-nr:false" }) { start.ArgumentList.Add (argument); } @@ -480,8 +550,13 @@ string Build (string project, bool expectSuccess, params string [] arguments) using var process = Process.Start (start) ?? throw new InvalidOperationException ("Could not start MSBuild."); var stdout = process.StandardOutput.ReadToEndAsync (); var stderr = process.StandardError.ReadToEndAsync (); - Assert.True (process.WaitForExit (120000), "MSBuild did not finish."); + bool completed = process.WaitForExit (120000); + if (!completed) { + process.Kill (entireProcessTree: true); + process.WaitForExit (); + } var output = stdout.GetAwaiter ().GetResult () + stderr.GetAwaiter ().GetResult (); + Assert.True (completed, "MSBuild did not finish." + Environment.NewLine + output); Assert.True (expectSuccess ? process.ExitCode == 0 : process.ExitCode != 0, output); return output; } @@ -493,5 +568,14 @@ string Write (string name, string content) return path; } + string WriteNativeObject (string name, params string [] keys) => + NativeAotObjectTestFixture.WriteObject (directory, name, NativeAotObjectIntegrationTools.LlvmReadObjPath, keys); + + static XElement NativeObjectItem (string output, string nativeObject) => + new ("ResolvedFileToPublish", new XAttribute ("Include", output), + new XAttribute ("AndroidTypeMapNativeObject", nativeObject), + new XAttribute ("AndroidTypeMapLlvmReadObjPath", NativeAotObjectIntegrationTools.LlvmReadObjPath), + new XAttribute ("AndroidTypeMapLlvmObjDumpPath", NativeAotObjectIntegrationTools.LlvmObjDumpPath)); + public void Dispose () => Directory.Delete (directory, recursive: true); } diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests.csproj b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests.csproj index cbb7e02134f..efa468f4319 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests.csproj +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests.csproj @@ -48,6 +48,7 @@ + diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/NativeAotObjectIntegrationTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/NativeAotObjectIntegrationTests.cs new file mode 100644 index 00000000000..73d7302386f --- /dev/null +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/NativeAotObjectIntegrationTests.cs @@ -0,0 +1,31 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using Xunit; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap.Tests; + +static class NativeAotObjectIntegrationTools +{ + public static string LlvmReadObjPath => + typeof (NativeAotObjectIntegrationTools).Assembly.GetCustomAttributes () + .Single (attribute => attribute.Key == "NativeAotLlvmReadObjPath").Value ?? ""; + + public static string LlvmObjDumpPath => Path.Combine (Path.GetDirectoryName (LlvmReadObjPath) ?? "", + OperatingSystem.IsWindows () ? "llvm-objdump.exe" : "llvm-objdump"); + + public static string? SkipReason => File.Exists (LlvmReadObjPath) && File.Exists (LlvmObjDumpPath) + ? null + : "Set _NativeAotLlvmReadObjPath to the NDK llvm-readobj executable with adjacent llvm-objdump to run native-object integration tests."; +} + +sealed class NativeAotObjectFactAttribute : FactAttribute +{ + public NativeAotObjectFactAttribute () => Skip = NativeAotObjectIntegrationTools.SkipReason; +} + +sealed class NativeAotObjectTheoryAttribute : TheoryAttribute +{ + public NativeAotObjectTheoryAttribute () => Skip = NativeAotObjectIntegrationTools.SkipReason; +} From 8b079f82b7befcf6d4df3e5ed6dc39d1e5fea55f Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 18 Sep 2026 10:47:26 +0200 Subject: [PATCH 24/25] Remove remaining DGML-based NativeAOT logic Keep runtime interface-collection assertions and graph-free pipeline regressions, while removing obsolete graph analysis, fixtures, serialization, and guidance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Documentation/docs-mobile/messages/xa4319.md | 26 +- Documentation/docs-mobile/messages/xa4320.md | 24 +- Documentation/docs-mobile/messages/xa4321.md | 26 +- Documentation/guides/D8andR8.md | 5 +- ...id.Sdk.TypeMap.Trimmable.NativeAOT.targets | 4 - .../Xamarin.Android.Build.Tests/BuildTest2.cs | 4 +- .../Utilities/BaseTest.cs | 4 +- .../InterfaceCollectionApp/rooting.dgml.xml | 51 --- .../Tests/InterfaceCollectionRootingTests.cs | 120 ------ .../Tests/InterfaceCollectionTests.cs | 388 ------------------ .../Generator/TypeMapProguardTests.cs | 14 +- 11 files changed, 26 insertions(+), 640 deletions(-) delete mode 100644 tests/MSBuildDeviceIntegration/Resources/InterfaceCollectionApp/rooting.dgml.xml delete mode 100644 tests/MSBuildDeviceIntegration/Tests/InterfaceCollectionRootingTests.cs diff --git a/Documentation/docs-mobile/messages/xa4319.md b/Documentation/docs-mobile/messages/xa4319.md index d5a1bb9e09c..eb4bd3eb291 100644 --- a/Documentation/docs-mobile/messages/xa4319.md +++ b/Documentation/docs-mobile/messages/xa4319.md @@ -1,31 +1,13 @@ --- title: .NET for Android error XA4319 description: XA4319 error code -ms.date: 05/27/2026 +ms.date: 09/18/2026 f1_keywords: - "XA4319" --- # .NET for Android error XA4319 -This diagnostic was retired when NativeAOT typemap extraction moved from DGML -graphs to ILC object files. Current builds do not require dependency graphs -for Java trimming; object extraction failures use [XA4327](xa4327.md). -The following describes the earlier DGML-based pipeline. - -## Example messages - -``` -error XA4319: No NativeAOT DGML files were provided. -``` - -## Issue - -The .NET for Android build could not find any NativeAOT DGML files to use when -generating trimmable type map ProGuard configuration. - -## Solution - -This error is expected only for internal build state inconsistencies. Ensure the -project is building with the intended Runtime Identifier settings and that the -NativeAOT compile produced its DGML scan file. +This diagnostic has been retired. NativeAOT Java trimming reads ILC object +files and does not require dependency graphs. See [XA4327](xa4327.md) for +current typemap extraction failures. diff --git a/Documentation/docs-mobile/messages/xa4320.md b/Documentation/docs-mobile/messages/xa4320.md index 0f5aeada47f..648c4128aef 100644 --- a/Documentation/docs-mobile/messages/xa4320.md +++ b/Documentation/docs-mobile/messages/xa4320.md @@ -1,29 +1,13 @@ --- title: .NET for Android error XA4320 description: XA4320 error code -ms.date: 05/27/2026 +ms.date: 09/18/2026 f1_keywords: - "XA4320" --- # .NET for Android error XA4320 -This diagnostic was retired when NativeAOT typemap extraction moved from the -DGML/ACW join to ILC object files. Current object extraction failures use -[XA4327](xa4327.md). The following describes the earlier DGML-based pipeline. - -## Example messages - -``` -error XA4320: ACW map file '{file}' was not found. -``` - -## Issue - -The .NET for Android build could not find the Android Callable Wrapper (ACW) map -file needed to generate trimmable type map ProGuard configuration. - -## Solution - -This error is expected only for internal build state inconsistencies. Rebuild -the project from a clean state so the ACW map is regenerated. +This diagnostic has been retired. NativeAOT Java trimming reads ILC object +files instead of joining dependency graphs with an ACW map. See +[XA4327](xa4327.md) for current typemap extraction failures. diff --git a/Documentation/docs-mobile/messages/xa4321.md b/Documentation/docs-mobile/messages/xa4321.md index a33660ceb3b..3574fb5e09e 100644 --- a/Documentation/docs-mobile/messages/xa4321.md +++ b/Documentation/docs-mobile/messages/xa4321.md @@ -1,31 +1,13 @@ --- title: .NET for Android error XA4321 description: XA4321 error code -ms.date: 05/27/2026 +ms.date: 09/18/2026 f1_keywords: - "XA4321" --- # .NET for Android error XA4321 -This diagnostic was retired when NativeAOT typemap extraction moved from DGML -graphs to ILC object files. Current builds do not require dependency graphs -for Java trimming; object extraction failures use [XA4327](xa4327.md). -The following describes the earlier DGML-based pipeline. - -## Example messages - -``` -error XA4321: NativeAOT DGML file '{file}' was not found. -``` - -## Issue - -The .NET for Android build could not find a NativeAOT DGML scan file needed to -generate trimmable type map ProGuard configuration. - -## Solution - -This error is expected only for internal build state inconsistencies. Rebuild -the project from a clean state and verify the NativeAOT compile produced the -DGML scan file for the selected Runtime Identifier. +This diagnostic has been retired. NativeAOT Java trimming reads ILC object +files and does not require dependency graphs. See [XA4327](xa4327.md) for +current typemap extraction failures. diff --git a/Documentation/guides/D8andR8.md b/Documentation/guides/D8andR8.md index 0f897bc8725..30cb730f572 100644 --- a/Documentation/guides/D8andR8.md +++ b/Documentation/guides/D8andR8.md @@ -74,9 +74,8 @@ pipeline. It replaces the old NativeAOT-specific trimming and ProGuard switches. Leave it unset for automatic CoreCLR behavior, set it to `true` to enable the pipeline in eligible managed-trimmed CoreCLR/NativeAOT R8 builds, or set it to `false` to use legacy ACW retention without running the new tasks. Disabling -it avoids NativeAOT object inspection. Explicit `IlcGenerateDgmlFile` -diagnostics are independent and remain honored. The switch does not enable R8 -or managed trimming in otherwise ineligible builds. +it avoids NativeAOT object inspection. The switch does not enable R8 or managed +trimming in otherwise ineligible builds. NativeAOT object inspection currently requires explicit `true` and the Android NDK's `llvm-readobj` and adjacent `llvm-objdump`, even when using the diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.NativeAOT.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.NativeAOT.targets index b1daf833740..ba8a1eca070 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.NativeAOT.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.NativeAOT.targets @@ -3,10 +3,6 @@ - - <_AndroidBuildRuntimeIdentifiersInParallel - Condition=" '$(IlcGenerateDgmlFile)' == 'true' and '$(_AndroidBuildRuntimeIdentifiersInParallel)' == '' ">false <_TrimmableRuntimeProviderJavaName Condition=" '$(_TrimmableRuntimeProviderJavaName)' == '' ">net.dot.jni.nativeaot.NativeAotRuntimeProvider r8 d8 diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildTest2.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildTest2.cs index 4f52bf67ff0..e170f8d9638 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildTest2.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildTest2.cs @@ -1635,8 +1635,8 @@ public void NativeAotKeepsRuntimeAcwJavaTypesUnderR8 () var dexFile = Path.Combine (intermediate, "android", "bin", "classes.dex"); FileAssert.Exists (dexFile); - // Regression test: the trimmable NativeAOT path generates its ACW keep rules from the - // ILC DGML into proguard_project_references.cfg. If that file is not passed to R8, R8 + // Regression test: NativeAOT must pass its generated ACW keep rules in + // proguard_project_references.cfg to R8. Otherwise R8 // tree-shakes the runtime ACW/JCW classes out of classes.dex and the app crashes at // startup inside JavaInteropRuntime.init with a ClassNotFoundException for the // UncaughtExceptionMarshaler Java Callable Wrapper. The JCW class name is CRC-hashed diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/BaseTest.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/BaseTest.cs index d88603519bb..55d6b36cd06 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/BaseTest.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/BaseTest.cs @@ -638,11 +638,11 @@ protected bool IgnoreUnsupportedConfiguration (AndroidRuntime runtime, bool aot // NativeAOT trims with ILC and does not emit illink's `obj///linked/` output. // Tests that inspect the `linked/` directory (e.g. to verify trimming or type-map behavior) // therefore cannot run as-is on NativeAOT. - // TODO: add DGML-based counterparts to verify these behaviors on NativeAOT (follow-up issue). + // NativeAOT typemap retention is covered by native object extraction tests. protected bool IgnoreNativeAotLinkedAssemblyChecks (AndroidRuntime runtime) { if (runtime == AndroidRuntime.NativeAOT) { - Assert.Ignore ("NativeAOT does not produce illink's `linked/` output; skipping `linked/` assembly inspection (DGML counterpart tracked as a follow-up)."); + Assert.Ignore ("NativeAOT produces ILC native objects rather than illink's `linked/` assemblies; skipping linked assembly inspection."); return true; } diff --git a/tests/MSBuildDeviceIntegration/Resources/InterfaceCollectionApp/rooting.dgml.xml b/tests/MSBuildDeviceIntegration/Resources/InterfaceCollectionApp/rooting.dgml.xml deleted file mode 100644 index 336f4cd924f..00000000000 --- a/tests/MSBuildDeviceIntegration/Resources/InterfaceCollectionApp/rooting.dgml.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tests/MSBuildDeviceIntegration/Tests/InterfaceCollectionRootingTests.cs b/tests/MSBuildDeviceIntegration/Tests/InterfaceCollectionRootingTests.cs deleted file mode 100644 index f94e19bbec4..00000000000 --- a/tests/MSBuildDeviceIntegration/Tests/InterfaceCollectionRootingTests.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System.IO; -using System.Linq; -using System.Xml.Linq; - -using NUnit.Framework; -using Xamarin.ProjectTools; - -namespace Xamarin.Android.Build.Tests; - -[TestFixture] -public class InterfaceCollectionRootingTests : BaseTest -{ - static readonly XNamespace DgmlNamespace = "http://schemas.microsoft.com/vs/2009/dgml"; - - static string GraphPath => Path.Combine ( - XABuildPaths.TopDirectory, "tests", "MSBuildDeviceIntegration", "Resources", "InterfaceCollectionApp", "rooting.dgml.xml"); - - [Test] - public void CanonicalWrapperRootingGraph () - { - var path = TestContext.Parameters.Get ("InterfaceCollectionRootingGraph", GraphPath); - InterfaceCollectionTests.AssertCanonicalWrapperRooting (path); - } - - [TestCase ("list")] - [TestCase ("collection")] - [TestCase ("dictionary")] - public void DirectFactoryRooting (string wrapper) - { - var graph = XDocument.Load (GraphPath); - UseDirectFactoryRoot (graph, wrapper); - AssertGraph (graph); - } - - [TestCase ("dictionary-factory", "conditional factory primary dependency")] - [TestCase ("peer-metadata", "conditional factory metadata dependency")] - public void RejectsMissingConditionalInput (string source, string message) - { - var graph = XDocument.Load (GraphPath); - FindLink (graph, source, "dictionary-conditional").Remove (); - Assert.That (() => AssertGraph (graph), Throws.TypeOf ().With.Message.Contains (message)); - } - - [TestCase ("dictionary-factory", "conditional factory primary dependency")] - [TestCase ("peer-metadata", "conditional factory metadata dependency")] - public void RejectsWrongConditionalInput (string source, string message) - { - var graph = XDocument.Load (GraphPath); - FindLink (graph, source, "dictionary-conditional").SetAttributeValue ("Source", "collection-factory"); - Assert.That (() => AssertGraph (graph), Throws.TypeOf ().With.Message.Contains (message)); - } - - [TestCase (false)] - [TestCase (true)] - public void RejectsMissingAllocation (bool conditional) - { - var graph = XDocument.Load (GraphPath); - if (!conditional) { - UseDirectFactoryRoot (graph, "dictionary"); - } - FindLink (graph, conditional ? "dictionary-conditional" : "dictionary-factory", "dictionary-type").Remove (); - Assert.That (() => AssertGraph (graph), Throws.TypeOf ().With.Message.Contains ("newobj dependency was not found")); - } - - [TestCase (false)] - [TestCase (true)] - public void RejectsUnexpectedRoot (bool conditional) - { - var graph = XDocument.Load (GraphPath); - if (!conditional) { - UseDirectFactoryRoot (graph, "dictionary"); - } - var unexpectedLink = new XElement (FindLink ( - graph, conditional ? "dictionary-conditional" : "dictionary-factory", "dictionary-type")); - unexpectedLink.SetAttributeValue ("Source", "collection-factory"); - graph.Descendants (DgmlNamespace + "Links").Single ().Add (unexpectedLink); - Assert.That (() => AssertGraph (graph), Throws.TypeOf ().With.Message.Contains ("unexpected incoming dependency")); - } - - [TestCase ("dictionary-factory")] - [TestCase ("dictionary-conditional")] - public void RejectsAmbiguousFactoryNodes (string nodeId) - { - var graph = XDocument.Load (GraphPath); - var duplicate = new XElement (FindNode (graph, nodeId)); - duplicate.SetAttributeValue ("Id", "duplicate"); - graph.Descendants (DgmlNamespace + "Nodes").Single ().Add (duplicate); - Assert.That (() => AssertGraph (graph), Throws.TypeOf ().With.Message.Contains ("ambiguous node matches")); - } - - static void UseDirectFactoryRoot (XDocument graph, string wrapper) - { - FindLink (graph, $"{wrapper}-conditional", $"{wrapper}-type").SetAttributeValue ("Source", $"{wrapper}-factory"); - FindLink (graph, $"{wrapper}-factory", $"{wrapper}-conditional").Remove (); - FindLink (graph, "peer-metadata", $"{wrapper}-conditional").Remove (); - FindNode (graph, $"{wrapper}-conditional").Remove (); - } - - static XElement FindNode (XDocument graph, string id) - { - return graph.Descendants (DgmlNamespace + "Node").Single (node => node.Attribute ("Id")?.Value == id); - } - - static XElement FindLink (XDocument graph, string source, string target) - { - return graph.Descendants (DgmlNamespace + "Link").Single ( - link => link.Attribute ("Source")?.Value == source && link.Attribute ("Target")?.Value == target); - } - - static void AssertGraph (XDocument graph) - { - var path = Path.GetTempFileName (); - try { - graph.Save (path); - InterfaceCollectionTests.AssertCanonicalWrapperRooting (path); - } finally { - File.Delete (path); - } - } -} diff --git a/tests/MSBuildDeviceIntegration/Tests/InterfaceCollectionTests.cs b/tests/MSBuildDeviceIntegration/Tests/InterfaceCollectionTests.cs index f609348f10e..cd7d56cf496 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InterfaceCollectionTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InterfaceCollectionTests.cs @@ -1,8 +1,6 @@ using System; -using System.Collections.Generic; using System.IO; using System.Text; -using System.Xml; using NUnit.Framework; @@ -16,7 +14,6 @@ namespace Xamarin.Android.Build.Tests [Category ("UsesDevice")] public class InterfaceCollectionTests : DeviceTest { - const string DgmlNamespace = "http://schemas.microsoft.com/vs/2009/dgml"; const string ResultPrefix = "INTERFACE_COLLECTION_ROOTING_RESULT"; [Test] @@ -66,12 +63,6 @@ public void InterfaceCollectionFactoryRootsCanonicalWrappers () }, logcatPath, ActivityStartTimeoutInSeconds, onMonitoringStarted: () => StartActivityAndAssert (proj)); Assert.IsTrue (resultFound, $"The focused app did not report a result. See '{logcatPath}'."); StringAssert.Contains ($"{ResultPrefix} PASS {resultToken}", resultLine); - - var projectDirectory = Path.Combine (Root, builder.ProjectDirectory); - var dgmlFiles = Directory.GetFiles (projectDirectory, $"{proj.ProjectName}.scan.dgml.xml", SearchOption.AllDirectories); - Assert.AreEqual (1, dgmlFiles.Length, "The focused NativeAOT app should produce one scan dependency graph."); - AssertCanonicalWrapperRooting (dgmlFiles [0]); - TestContext.Out.WriteLine ($"Focused NativeAOT dependency graph: {dgmlFiles [0]}"); } finally { RunAdbCommand ($"uninstall {proj.PackageName}"); } @@ -100,133 +91,6 @@ void AssertGeneratedBindingsAreIsolated (ProjectBuilder builder, XamarinAndroidA "The raw JNI holder and concrete peers must not produce managed bindings that can root closed collection wrappers."); } - internal static void AssertCanonicalWrapperRooting (string dgmlFile) - { - var chains = new [] { - new RootingChain ( - "JavaList", - "Mono_Android_Java_Interop_SafeJavaCollectionFactory__CreateReferenceListFromJniHandle", - "Mono_Android_Android_Runtime_JavaList_1 constructed", - "__GenericDict_Mono_Android_Android_Runtime_JavaList_1", - "(__GenericDict_Mono_Android_Android_Runtime_JavaList_1, " + - "Mono_Android_Android_Runtime_JavaList_1___ctor_0)", - "Mono_Android_Android_Runtime_JavaList_1___ctor_0", - "JavaList`1..ctor(native int,JniHandleOwnership)"), - new RootingChain ( - "JavaCollection", - "Mono_Android_Java_Interop_SafeJavaCollectionFactory__CreateReferenceCollectionFromJniHandle", - "Mono_Android_Android_Runtime_JavaCollection_1 constructed", - "__GenericDict_Mono_Android_Android_Runtime_JavaCollection_1", - "(__GenericDict_Mono_Android_Android_Runtime_JavaCollection_1, " + - "Mono_Android_Android_Runtime_JavaCollection_1___ctor)", - "Mono_Android_Android_Runtime_JavaCollection_1___ctor", - "JavaCollection`1..ctor(native int,JniHandleOwnership)"), - new RootingChain ( - "JavaDictionary", - "Mono_Android_Java_Interop_SafeJavaCollectionFactory__CreateReferenceDictionaryFromJniHandle", - "Mono_Android_Android_Runtime_JavaDictionary_2 constructed", - "__GenericDict_Mono_Android_Android_Runtime_JavaDictionary_2", - "(__GenericDict_Mono_Android_Android_Runtime_JavaDictionary_2, " + - "Mono_Android_Android_Runtime_JavaDictionary_2___ctor_0)", - "Mono_Android_Android_Runtime_JavaDictionary_2___ctor_0", - "JavaDictionary`2..ctor(native int,JniHandleOwnership)"), - }; - var duplicateNodeIds = new List (); - var missingNodeIds = new List (); - var nodeIds = new HashSet (StringComparer.Ordinal); - var unexpectedCanonicalRoots = new List (); - - using (var reader = CreateDgmlReader (dgmlFile)) { - while (reader.Read ()) { - if (reader.NodeType != XmlNodeType.Element || - reader.LocalName != "Node" || - reader.NamespaceURI != DgmlNamespace) { - continue; - } - var id = reader.GetAttribute ("Id") ?? ""; - var label = reader.GetAttribute ("Label") ?? ""; - if (id.Length == 0) { - missingNodeIds.Add (label); - } else if (!nodeIds.Add (id)) { - duplicateNodeIds.Add ($"Id=\"{id}\" Label=\"{label}\""); - } - foreach (var chain in chains) { - chain.ObserveNode (id, label); - } - if (IsUnexpectedCanonicalReferenceConstructor (label)) { - unexpectedCanonicalRoots.Add (label); - } - } - } - - using (var reader = CreateDgmlReader (dgmlFile)) { - while (reader.Read ()) { - if (reader.NodeType != XmlNodeType.Element || - reader.LocalName != "Link" || - reader.NamespaceURI != DgmlNamespace) { - continue; - } - var source = reader.GetAttribute ("Source") ?? ""; - var target = reader.GetAttribute ("Target") ?? ""; - var reason = reader.GetAttribute ("Reason") ?? ""; - foreach (var chain in chains) { - chain.ObserveLink (source, target, reason); - } - } - } - - Assert.IsEmpty (missingNodeIds, "The NativeAOT dependency graph contained nodes without IDs."); - Assert.IsEmpty (duplicateNodeIds, "The NativeAOT dependency graph contained duplicate node IDs."); - Assert.IsEmpty ( - unexpectedCanonicalRoots, - "Only SafeJavaCollectionFactory's IJavaPeerable instantiations should root the reference-wrapper canonical constructors."); - foreach (var chain in chains) { - chain.AssertComplete (); - TestContext.Out.WriteLine ($"{chain.Name} canonical constructor rooted through SafeJavaCollectionFactory."); - } - } - - static XmlReader CreateDgmlReader (string dgmlFile) - { - return XmlReader.Create (dgmlFile, new XmlReaderSettings { - DtdProcessing = DtdProcessing.Prohibit, - XmlResolver = null, - }); - } - - static bool IsUnexpectedCanonicalReferenceConstructor (string label) - { - if (!label.Contains ("..ctor(native int,JniHandleOwnership) backed by ", StringComparison.Ordinal)) { - return false; - } - bool usesReferenceCanonicalCode = - label.Contains ("JavaList_1___ctor_0", StringComparison.Ordinal) || - label.Contains ("JavaCollection_1___ctor", StringComparison.Ordinal) || - label.Contains ("JavaDictionary_2___ctor_0", StringComparison.Ordinal); - if (!usesReferenceCanonicalCode) { - return false; - } - bool isExpectedRoot = - label == "[Mono.Android]Android.Runtime.JavaList`1..ctor(native int,JniHandleOwnership) " + - "backed by Mono_Android_Android_Runtime_JavaList_1___ctor_0" || - label == "[Mono.Android]Android.Runtime.JavaList`1..ctor(native int,JniHandleOwnership) " + - "backed by Mono_Android_Android_Runtime_JavaList_1___ctor_0" || - label == "[Mono.Android]Android.Runtime.JavaCollection`1..ctor(native int,JniHandleOwnership) " + - "backed by Mono_Android_Android_Runtime_JavaCollection_1___ctor" || - label == "[Mono.Android]Android.Runtime.JavaCollection`1..ctor(native int,JniHandleOwnership) " + - "backed by Mono_Android_Android_Runtime_JavaCollection_1___ctor" || - label == "[Mono.Android]Android.Runtime.JavaDictionary`2" + - "..ctor(native int,JniHandleOwnership) backed by " + - "Mono_Android_Android_Runtime_JavaDictionary_2___ctor_0" || - label == "[Mono.Android]Android.Runtime.JavaDictionary`2" + - "..ctor(native int,JniHandleOwnership) backed by " + - "Mono_Android_Android_Runtime_JavaDictionary_2___ctor_0"; - return !isExpectedRoot; - } - static string ReadFixture (string fileName) { return File.ReadAllText ( @@ -244,257 +108,5 @@ static string ReadRuntimeFixture (string fileName) return File.ReadAllText ( Path.Combine (XABuildPaths.TopDirectory, "tests", "Mono.Android-Tests", "Mono.Android-Tests", fileName)); } - - sealed class RootingChain - { - const string ReferenceTypeMetadata = "Type metadata: [Java.Interop]Java.Interop.IJavaPeerable"; - - readonly string constructorPattern; - readonly string canonicalConstructorPattern; - readonly string constructedTypePattern; - readonly string genericDictionaryPattern; - readonly string genericDictionaryDependencyPattern; - readonly string sourcePattern; - readonly string conditionalSourcePattern; - readonly List ambiguousNodeMatches = new (); - readonly HashSet observedNodeRoles = new (StringComparer.Ordinal); - readonly List unexpectedIncomingLinks = new (); - - string canonicalConstructorId = ""; - string constructedTypeId = ""; - string constructorId = ""; - string genericDictionaryId = ""; - string genericDictionaryDependencyId = ""; - string sourceId = ""; - string conditionalSourceId = ""; - string referenceTypeMetadataId = ""; - bool canonicalConstructorToDependency; - bool constructedTypeToGenericDictionary; - bool genericDictionaryToDependency; - bool genericDictionaryToConstructor; - bool sourceToConstructedType; - bool conditionalSourceToConstructedType; - bool sourceToConditionalSource; - bool metadataToConditionalSource; - - public RootingChain ( - string name, - string sourcePattern, - string constructedTypePattern, - string genericDictionaryPattern, - string genericDictionaryDependencyPattern, - string canonicalConstructorPattern, - string constructorPattern) - { - Name = name; - this.sourcePattern = sourcePattern; - conditionalSourcePattern = $"({sourcePattern}, {ReferenceTypeMetadata})"; - this.constructedTypePattern = constructedTypePattern; - this.genericDictionaryPattern = genericDictionaryPattern; - this.genericDictionaryDependencyPattern = genericDictionaryDependencyPattern; - this.canonicalConstructorPattern = canonicalConstructorPattern; - this.constructorPattern = constructorPattern; - } - - public string Name { get; } - - public void ObserveNode (string id, string label) - { - int matchedRoles = 0; - matchedRoles += ObserveNode ( - label == sourcePattern, - id, - label, - "SafeJavaCollectionFactory source", - ref sourceId) ? 1 : 0; - matchedRoles += ObserveNode ( - label == conditionalSourcePattern, - id, - label, - "conditional factory dependency", - ref conditionalSourceId) ? 1 : 0; - matchedRoles += ObserveNode ( - label == ReferenceTypeMetadata, - id, - label, - "IJavaPeerable type metadata", - ref referenceTypeMetadataId) ? 1 : 0; - matchedRoles += ObserveNode ( - IsConstructedTypeLabel (label, constructedTypePattern), - id, - label, - "IJavaPeerable constructed type", - ref constructedTypeId) ? 1 : 0; - matchedRoles += ObserveNode ( - label == genericDictionaryPattern, - id, - label, - "IJavaPeerable generic dictionary", - ref genericDictionaryId) ? 1 : 0; - matchedRoles += ObserveNode ( - label == genericDictionaryDependencyPattern, - id, - label, - "IJavaPeerable constructor dictionary dependency", - ref genericDictionaryDependencyId) ? 1 : 0; - matchedRoles += ObserveNode ( - label == canonicalConstructorPattern, - id, - label, - "canonical compiled constructor", - ref canonicalConstructorId) ? 1 : 0; - matchedRoles += ObserveNode ( - label == $"[Mono.Android]Android.Runtime.{constructorPattern} backed by {canonicalConstructorPattern}", - id, - label, - "IJavaPeerable activation constructor", - ref constructorId) ? 1 : 0; - if (matchedRoles > 1) { - ambiguousNodeMatches.Add ($"multiple roles: Id=\"{id}\" Label=\"{label}\""); - } - } - - public void ObserveLink (string source, string target, string reason) - { - sourceToConstructedType |= IsLink (source, target, reason, sourceId, constructedTypeId, "newobj"); - // ILC may represent the type guard as a conditional dependency instead of a direct newobj edge. - conditionalSourceToConstructedType |= IsLink (source, target, reason, conditionalSourceId, constructedTypeId, "newobj"); - sourceToConditionalSource |= IsLink (source, target, reason, sourceId, conditionalSourceId, "Primary"); - metadataToConditionalSource |= IsLink (source, target, reason, referenceTypeMetadataId, conditionalSourceId, "Secondary"); - constructedTypeToGenericDictionary |= IsLink (source, target, reason, constructedTypeId, genericDictionaryId, "reloc"); - genericDictionaryToDependency |= IsLink ( - source, - target, - reason, - genericDictionaryId, - genericDictionaryDependencyId, - "Primary"); - canonicalConstructorToDependency |= IsLink ( - source, - target, - reason, - canonicalConstructorId, - genericDictionaryDependencyId, - "Secondary"); - genericDictionaryToConstructor |= IsLink ( - source, - target, - reason, - genericDictionaryDependencyId, - constructorId, - "Generic dictionary dependency"); - - RejectUnexpectedIncoming (source, target, reason, constructedTypeId, sourceId, "newobj", conditionalSourceId, "newobj"); - RejectUnexpectedIncoming ( - source, target, reason, conditionalSourceId, sourceId, "Primary", referenceTypeMetadataId, "Secondary"); - RejectUnexpectedIncoming (source, target, reason, genericDictionaryId, constructedTypeId, "reloc"); - RejectUnexpectedIncoming ( - source, target, reason, genericDictionaryDependencyId, genericDictionaryId, "Primary", canonicalConstructorId, "Secondary"); - RejectUnexpectedIncoming ( - source, - target, - reason, - constructorId, - genericDictionaryDependencyId, - "Generic dictionary dependency"); - } - - public void AssertComplete () - { - Assert.IsEmpty (ambiguousNodeMatches, $"{Name} canonical constructor path had ambiguous node matches."); - Assert.IsNotEmpty (sourceId, $"{Name} SafeJavaCollectionFactory source node was not found."); - Assert.IsNotEmpty (constructedTypeId, $"{Name} IJavaPeerable constructed-type node was not found."); - Assert.IsNotEmpty (genericDictionaryId, $"{Name} IJavaPeerable generic dictionary node was not found."); - Assert.IsNotEmpty (genericDictionaryDependencyId, $"{Name} IJavaPeerable constructor dictionary dependency was not found."); - Assert.IsNotEmpty (canonicalConstructorId, $"{Name} canonical compiled constructor node was not found."); - Assert.IsNotEmpty (constructorId, $"{Name} IJavaPeerable activation constructor node was not found."); - Assert.IsTrue ( - sourceToConstructedType || conditionalSourceToConstructedType, - $"{Name} SafeJavaCollectionFactory newobj dependency was not found."); - if (conditionalSourceToConstructedType) { - Assert.IsTrue (sourceToConditionalSource, $"{Name} conditional factory primary dependency was not found."); - Assert.IsTrue (metadataToConditionalSource, $"{Name} conditional factory metadata dependency was not found."); - } - Assert.IsTrue (constructedTypeToGenericDictionary, $"{Name} constructed-type relocation dependency was not found."); - Assert.IsTrue (genericDictionaryToDependency, $"{Name} generic dictionary primary dependency was not found."); - Assert.IsTrue (canonicalConstructorToDependency, $"{Name} canonical constructor secondary dependency was not found."); - Assert.IsTrue (genericDictionaryToConstructor, $"{Name} generic dictionary constructor dependency was not found."); - Assert.IsEmpty (unexpectedIncomingLinks, $"{Name} canonical constructor path had an unexpected incoming dependency."); - } - - bool ObserveNode (bool matches, string id, string label, string role, ref string observedId) - { - if (!matches) { - return false; - } - if (!observedNodeRoles.Add (role)) { - ambiguousNodeMatches.Add ($"{role}: Id=\"{id}\" Label=\"{label}\""); - return true; - } - observedId = id; - return true; - } - - void RejectUnexpectedIncoming ( - string source, - string target, - string reason, - string expectedTarget, - string expectedSource, - string expectedReason, - string alternativeSource = "", - string alternativeReason = "") - { - if (IsIncomingLink (target, expectedTarget) && - !IsLink (source, target, reason, expectedSource, expectedTarget, expectedReason) && - !IsLink (source, target, reason, alternativeSource, expectedTarget, alternativeReason)) { - unexpectedIncomingLinks.Add (FormatLink (source, target, reason)); - } - } - - static bool IsIncomingLink (string actualTarget, string expectedTarget) - { - return expectedTarget.Length > 0 && actualTarget == expectedTarget; - } - - static bool IsLink ( - string actualSource, - string actualTarget, - string actualReason, - string expectedSource, - string expectedTarget, - string expectedReason) - { - return expectedSource.Length > 0 && - expectedTarget.Length > 0 && - actualSource == expectedSource && - actualTarget == expectedTarget && - actualReason == expectedReason; - } - - static string FormatLink (string source, string target, string reason) - { - return $"Source=\"{source}\" Target=\"{target}\" Reason=\"{reason}\""; - } - - static bool IsConstructedTypeLabel (string label, string constructedTypePattern) - { - if (!label.EndsWith (constructedTypePattern, StringComparison.Ordinal)) { - return false; - } - - int prefixLength = label.Length - constructedTypePattern.Length; - if (prefixLength <= "_ZTV".Length || - !label.StartsWith ("_ZTV", StringComparison.Ordinal)) { - return false; - } - for (int i = "_ZTV".Length; i < prefixLength; i++) { - if (label [i] < '0' || label [i] > '9') { - return false; - } - } - return true; - } - } } } diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs index 2fc5fc1a63c..043f2103018 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs @@ -294,10 +294,11 @@ public void EnablingDisablingAndUnsettingCannotReuseLegacyRules (string runtime, } [Theory] - [InlineData ("false", "false", false)] - [InlineData ("false", "true", true)] - [InlineData ("true", "false", false)] - public void DisablingAutomaticDgmlPreservesExplicitDiagnostics (string enabled, string diagnostics, bool serialBuild) + [InlineData ("false", "false")] + [InlineData ("false", "true")] + [InlineData ("true", "false")] + [InlineData ("true", "true")] + public void TypemapInputsDoNotRequestGraphsOrChangeParallelism (string enabled, string diagnostics) { var project = CreateProject ("NativeAOT", "trimmable"); var document = XDocument.Load (project); @@ -312,10 +313,11 @@ public void DisablingAutomaticDgmlPreservesExplicitDiagnostics (string enabled, new XAttribute ("Overwrite", "true")))); document.Save (project); Build (project, "-p:_AndroidEnableTypemapR8Trimming=" + enabled, "-p:IlcGenerateDgmlFile=" + diagnostics, "-p:Optimize=true"); - var output = File.ReadAllText (Path.Combine (directory, "ilc.txt")); + var lines = File.ReadAllLines (Path.Combine (directory, "ilc.txt")); + var output = string.Join ("\n", lines); Assert.DoesNotContain ("--scandgmllog:", output); Assert.DoesNotContain ("--dgmllog:", output); - Assert.Equal (serialBuild, output.Contains ("Parallel=false", StringComparison.Ordinal)); + Assert.Contains ("Parallel=", lines); Assert.Contains ("Diagnostics=" + diagnostics, output); } From 87bd3f8a8b512859339e5446c1f86709a6c28c3b Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 18 Sep 2026 12:23:46 +0200 Subject: [PATCH 25/25] Optimize retained CoreCLR Java code without obfuscating JNI names Select the optimizing Android R8 defaults for the active CoreCLR typemap pipeline while preserving -dontobfuscate, --no-minification and all keep rules. Leave NativeAOT, legacy modes and complete configuration overrides unchanged. Cover actual config selection and R8 metadata, and allow optimizer inlining of Java-only helpers in the JNI device regression. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../building-apps/build-properties.md | 6 ++++ Documentation/guides/D8andR8.md | 4 +++ .../TrimmableTypeMapBuildTests.cs | 6 ++++ .../Xamarin.Android.Common.targets | 4 +-- .../Tests/ScopedTypeMapR8Tests.cs | 3 +- .../Generator/TypeMapProguardTests.cs | 36 +++++++++++++++++++ 6 files changed, 56 insertions(+), 3 deletions(-) diff --git a/Documentation/docs-mobile/building-apps/build-properties.md b/Documentation/docs-mobile/building-apps/build-properties.md index dc0a3d7f918..2e2ea53cde5 100644 --- a/Documentation/docs-mobile/building-apps/build-properties.md +++ b/Documentation/docs-mobile/building-apps/build-properties.md @@ -1145,6 +1145,12 @@ An enum-style property that specifies how `r8` obfuscates Java names when This property does not disable R8 code shrinking. +When managed-trimmed CoreCLR builds use retained typemap rules, all Java +names are preserved regardless of this setting, but R8 optimization remains +enabled. This path uses the optimizing Android defaults together with +`-dontobfuscate` and `--no-minification`; disabling renaming does not disable +optimization. + This property was introduced in a .NET 10 servicing release. It defaults to `disabled` in .NET 10 and to `private-members` in .NET 11 and later. diff --git a/Documentation/guides/D8andR8.md b/Documentation/guides/D8andR8.md index 30cb730f572..95b82647c46 100644 --- a/Documentation/guides/D8andR8.md +++ b/Documentation/guides/D8andR8.md @@ -86,6 +86,10 @@ and keeps all ACW classes. This pipeline disables all obfuscation, including private-member obfuscation, with both `-dontobfuscate` and R8's `--no-minification` option. +CoreCLR uses `proguard-android-optimize.txt` so optimization remains enabled +independently of obfuscation. Its JNI class/member keep rules still protect +the names and signatures required by managed code. NativeAOT retains its +existing non-optimizing defaults. MonoVM, nonshrinking/multidex-only builds, and the existing complete `ProguardConfigFiles` override keep their previous behavior. diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs index e7865eb27c5..2d5bb4fd882 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs @@ -68,6 +68,12 @@ public void RetainedTypeMapClassRulesDriveR8 (AndroidRuntime runtime, string imp var dex = Path.Combine (intermediate, "android", "bin", "classes.dex"); Assert.IsTrue (DexUtils.ContainsClass ($"L{live};", dex, AndroidSdkPath), "Live class names must not be obfuscated."); Assert.IsFalse (DexUtils.ContainsClass ($"L{dead};", dex, AndroidSdkPath), "Dead wrappers must not be rooted by runtime package rules."); + using (var metadata = JsonDocument.Parse (File.ReadAllText (Path.Combine (intermediate, "r8.json")))) { + var options = metadata.RootElement.GetProperty ("options"); + Assert.IsTrue (options.GetProperty ("isShrinkingEnabled").GetBoolean ()); + Assert.IsFalse (options.GetProperty ("isObfuscationEnabled").GetBoolean ()); + Assert.AreEqual (runtime == AndroidRuntime.CoreCLR, options.GetProperty ("isOptimizationsEnabled").GetBoolean ()); + } Assert.IsTrue (builder.Build (proj)); builder.Output.AssertTargetIsSkipped ("_AndroidExtractTypeMapKeys"); diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets index d386927ac0a..f015bc5295c 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets @@ -2031,8 +2031,8 @@ because xbuild doesn't support framework reference assemblies. <_ProguardConfiguration Include="$(ProguardConfigFiles)" /> - <_ProguardConfiguration Include="$(MSBuildThisFileDirectory)proguard-android.txt" Condition=" '$(AndroidR8ObfuscationMode)' == 'disabled' or '$(_AndroidUseTypeMapProguardConfiguration)' == 'true' " /> - <_ProguardConfiguration Include="$(MSBuildThisFileDirectory)proguard-android-optimize.txt" Condition=" '$(AndroidR8ObfuscationMode)' != 'disabled' and '$(_AndroidUseTypeMapProguardConfiguration)' != 'true' " /> + <_ProguardConfiguration Include="$(MSBuildThisFileDirectory)proguard-android.txt" Condition=" ('$(AndroidR8ObfuscationMode)' == 'disabled' or '$(_AndroidUseTypeMapProguardConfiguration)' == 'true') and !('$(_AndroidUseTypeMapProguardConfiguration)' == 'true' and '$(_AndroidRuntime)' == 'CoreCLR') " /> + <_ProguardConfiguration Include="$(MSBuildThisFileDirectory)proguard-android-optimize.txt" Condition=" ('$(AndroidR8ObfuscationMode)' != 'disabled' and '$(_AndroidUseTypeMapProguardConfiguration)' != 'true') or ('$(_AndroidUseTypeMapProguardConfiguration)' == 'true' and '$(_AndroidRuntime)' == 'CoreCLR') " /> <_ProguardConfiguration Include="$(IntermediateOutputPath)proguard\proguard_xamarin.cfg" Condition=" '$(AndroidLinkTool)' != '' " /> <_ProguardConfiguration Include="$(_ProguardProjectConfiguration)" Condition=" '$(AndroidLinkTool)' != '' " /> <_ProguardConfiguration Include="$(IntermediateOutputPath)proguard\proguard_project_primary.cfg" Condition=" '$(AndroidLinkTool)' != '' " /> diff --git a/tests/MSBuildDeviceIntegration/Tests/ScopedTypeMapR8Tests.cs b/tests/MSBuildDeviceIntegration/Tests/ScopedTypeMapR8Tests.cs index 14c3e2c699f..b53ca8f00c1 100644 --- a/tests/MSBuildDeviceIntegration/Tests/ScopedTypeMapR8Tests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/ScopedTypeMapR8Tests.cs @@ -103,7 +103,8 @@ static void Require (int actual, int expected) { var projectDirectory = Path.Combine (Root, builder.ProjectDirectory); var dexFiles = Directory.GetFiles (Path.Combine (projectDirectory, proj.IntermediateOutputPath), "classes*.dex", SearchOption.AllDirectories); Assert.IsNotEmpty (dexFiles); - Assert.IsTrue (dexFiles.Any (dex => DexUtils.ContainsClassWithMethod ("Lexample/Helper;", "used", "()I", dex, AndroidSdkPath))); + Assert.IsTrue (dexFiles.Any (dex => DexUtils.ContainsClassWithMethod ("Lexample/ScopedPeer;", "keptMethod", "()I", dex, AndroidSdkPath)), + "The JNI entry point must survive, even when R8 inlines its Java-only helper."); Assert.IsFalse (dexFiles.Any (dex => DexUtils.ContainsClassWithMethod ("Lexample/Helper;", "unused", "()I", dex, AndroidSdkPath)), "Preserving JNI-facing peers must not keep unused members of Java-only dependencies."); Assert.IsFalse (dexFiles.Any (dex => DexUtils.ContainsClass ("Lexample/UnusedDependency;", dex, AndroidSdkPath))); diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs index 043f2103018..dd50a567cda 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapProguardTests.cs @@ -29,6 +29,42 @@ public void GeneratorUnionsCanonicalKeysAndWritesOnlyClassRules () Assert.Equal (new UTF8Encoding (false).GetBytes (expected), File.ReadAllBytes (task.OutputFile)); } + [Theory] + [InlineData ("CoreCLR", "true", "disabled", "proguard-android-optimize.txt")] + [InlineData ("CoreCLR", "true", "private-members", "proguard-android-optimize.txt")] + [InlineData ("CoreCLR", "false", "disabled", "proguard-android.txt")] + [InlineData ("CoreCLR", "false", "private-members", "proguard-android-optimize.txt")] + [InlineData ("NativeAOT", "true", "disabled", "proguard-android.txt")] + [InlineData ("NativeAOT", "true", "private-members", "proguard-android.txt")] + [InlineData ("MonoVM", "false", "disabled", "proguard-android.txt")] + public void PlatformConfigurationSeparatesCoreClrOptimizationFromObfuscation (string runtime, string typemap, string obfuscation, string expected) + { + var source = XDocument.Load (Path.Combine (RepositoryDirectory (), "src", "Xamarin.Android.Build.Tasks", "Xamarin.Android.Common.targets")); + var target = new XElement (source.Descendants ().Single (element => element.Name.LocalName == "Target" && (string?) element.Attribute ("Name") == "_CalculateProguardConfigurationFiles")); + foreach (var element in target.DescendantsAndSelf ()) { + element.Name = element.Name.LocalName; + } + var project = Path.Combine (directory, "configuration.proj"); + new XDocument (new XElement ("Project", + new XElement ("PropertyGroup", + new XElement ("_AndroidRuntime", runtime), + new XElement ("_AndroidUseTypeMapProguardConfiguration", typemap), + new XElement ("AndroidR8ObfuscationMode", obfuscation), + new XElement ("AndroidLinkTool", "r8"), + new XElement ("IntermediateOutputPath", "obj/")), + target, + new XElement ("Target", new XAttribute ("Name", "Build"), new XAttribute ("DependsOnTargets", "_CalculateProguardConfigurationFiles"), + new XElement ("WriteLinesToFile", new XAttribute ("File", "$(MSBuildProjectDirectory)/configurations.txt"), + new XAttribute ("Lines", "@(_ProguardConfiguration)"), new XAttribute ("Overwrite", "true"))))) + .Save (project); + Build (project); + var configurations = File.ReadAllLines (Path.Combine (directory, "configurations.txt")); + Assert.Single (configurations, path => Path.GetFileName (path).StartsWith ("proguard-android", StringComparison.Ordinal)); + Assert.Contains (configurations, path => Path.GetFileName (path) == expected); + Build (project, "-p:ProguardConfigFiles=custom.cfg"); + Assert.Equal (["custom.cfg"], File.ReadAllLines (Path.Combine (directory, "configurations.txt"))); + } + [Fact] public void MemberGeneratorScopesRulesToCanonicalKeys () {