From ec076db49d06552a717c41a7b81c6b8ec54e66b7 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 27 Mar 2026 12:30:51 +0100 Subject: [PATCH 1/4] [TrimmableTypeMap] Root manifest-referenced types as unconditional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse the user's AndroidManifest.xml template for activity, service, receiver, and provider elements with android:name attributes. Mark matching scanned Java peer types as IsUnconditional = true so the ILLink TypeMap step preserves them even if no managed code references them directly. Changes: - JavaPeerInfo.IsUnconditional: init → set (must be mutated after scanning) - TrimmableTypeMapGenerator: add warn callback, RootManifestReferencedTypes() called between scanning and typemap generation - GenerateTrimmableTypeMap task: pass Log.LogWarning as warn callback - 4 new xUnit tests covering rooting, unresolved warnings, already-unconditional skip, and empty manifest Replaces #11016 (closed — depended on old PR shape with TaskLoggingHelper). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Scanner/JavaPeerInfo.cs | 4 +- .../TrimmableTypeMapGenerator.cs | 76 +++++++++++- .../Tasks/GenerateTrimmableTypeMap.cs | 4 +- .../TrimmableTypeMapGeneratorTests.cs | 113 ++++++++++++++++++ 4 files changed, 194 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs index aed62453042..fb76f9e2bd9 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs @@ -69,8 +69,10 @@ public sealed record JavaPeerInfo /// Types with component attributes ([Activity], [Service], etc.), /// custom views from layout XML, or manifest-declared components /// are unconditionally preserved (not trimmable). + /// May be set after scanning when the manifest references a type + /// that the scanner did not mark as unconditional. /// - public bool IsUnconditional { get; init; } + public bool IsUnconditional { get; set; } /// /// True for Application and Instrumentation types. These types cannot call diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs index 09e969e8c87..d2bc36e3444 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs @@ -10,10 +10,12 @@ namespace Microsoft.Android.Sdk.TrimmableTypeMap; public class TrimmableTypeMapGenerator { readonly Action log; + readonly Action? warn; - public TrimmableTypeMapGenerator (Action log) + public TrimmableTypeMapGenerator (Action log, Action? warn = null) { this.log = log ?? throw new ArgumentNullException (nameof (log)); + this.warn = warn; } /// @@ -38,6 +40,8 @@ public TrimmableTypeMapResult Execute ( return new TrimmableTypeMapResult ([], [], allPeers); } + RootManifestReferencedTypes (allPeers, manifestTemplatePath); + var generatedAssemblies = GenerateTypeMapAssemblies (allPeers, systemRuntimeVersion); var jcwPeers = allPeers.Where (p => !frameworkAssemblyNames.Contains (p.AssemblyName) @@ -142,4 +146,74 @@ List GenerateJcwJavaSources (List allPeers) log ($"Generated {sources.Count} JCW Java source files."); return sources.ToList (); } + + void RootManifestReferencedTypes (List allPeers, string? manifestTemplatePath) + { + if (manifestTemplatePath.IsNullOrEmpty () || !File.Exists (manifestTemplatePath)) { + return; + } + + XDocument doc; + try { + doc = XDocument.Load (manifestTemplatePath); + } catch (Exception ex) { + warn?.Invoke ($"Failed to parse ManifestTemplate '{manifestTemplatePath}': {ex.Message}"); + return; + } + + RootManifestReferencedTypes (allPeers, doc); + } + + internal void RootManifestReferencedTypes (List allPeers, XDocument doc) + { + var root = doc.Root; + if (root is null) { + return; + } + + XNamespace androidNs = "http://schemas.android.com/apk/res/android"; + XName attName = androidNs + "name"; + + var componentNames = new HashSet (StringComparer.Ordinal); + foreach (var element in root.Descendants ()) { + switch (element.Name.LocalName) { + case "activity": + case "service": + case "receiver": + case "provider": + var name = (string?) element.Attribute (attName); + if (name is not null) { + componentNames.Add (name); + } + break; + } + } + + if (componentNames.Count == 0) { + return; + } + + var peersByDotName = new Dictionary> (StringComparer.Ordinal); + foreach (var peer in allPeers) { + var dotName = peer.JavaName.Replace ('/', '.').Replace ('$', '.'); + if (!peersByDotName.TryGetValue (dotName, out var list)) { + list = []; + peersByDotName [dotName] = list; + } + list.Add (peer); + } + + foreach (var name in componentNames) { + if (peersByDotName.TryGetValue (name, out var peers)) { + foreach (var peer in peers) { + if (!peer.IsUnconditional) { + peer.IsUnconditional = true; + log ($"Rooting manifest-referenced type '{name}' ({peer.ManagedTypeName}) as unconditional."); + } + } + } else { + warn?.Invoke ($"Manifest-referenced type '{name}' was not found in any scanned assembly. It may be a framework type."); + } + } + } } diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs index 96390fabaf8..4190707c504 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs @@ -97,7 +97,9 @@ public override bool RunTask () ApplicationJavaClass: ApplicationJavaClass); } -var generator = new TrimmableTypeMapGenerator (msg => Log.LogMessage (MessageImportance.Low, msg)); +var generator = new TrimmableTypeMapGenerator ( + msg => Log.LogMessage (MessageImportance.Low, msg), + msg => Log.LogWarning (msg)); XDocument? manifestTemplate = null; if (!ManifestTemplate.IsNullOrEmpty () && File.Exists (ManifestTemplate)) { diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs index 7be68db2eb4..2993c51bf3a 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs @@ -81,6 +81,119 @@ public void Execute_JavaSourcesHaveCorrectStructure () TrimmableTypeMapGenerator CreateGenerator () => new (msg => logMessages.Add (msg)); + TrimmableTypeMapGenerator CreateGenerator (List warnings) => + new (msg => logMessages.Add (msg), msg => warnings.Add (msg)); + + [Fact] + public void RootManifestReferencedTypes_RootsMatchingPeers () + { + var peers = new List { + new JavaPeerInfo { + JavaName = "com/example/MyActivity", CompatJniName = "com.example.MyActivity", + ManagedTypeName = "MyApp.MyActivity", ManagedTypeNamespace = "MyApp", ManagedTypeShortName = "MyActivity", + AssemblyName = "MyApp", IsUnconditional = false, + }, + new JavaPeerInfo { + JavaName = "com/example/MyService", CompatJniName = "com.example.MyService", + ManagedTypeName = "MyApp.MyService", ManagedTypeNamespace = "MyApp", ManagedTypeShortName = "MyService", + AssemblyName = "MyApp", IsUnconditional = false, + }, + }; + + var doc = System.Xml.Linq.XDocument.Parse (""" + + + + + + + """); + + var generator = CreateGenerator (); + generator.RootManifestReferencedTypes (peers, doc); + + Assert.True (peers [0].IsUnconditional, "MyActivity should be rooted as unconditional."); + Assert.False (peers [1].IsUnconditional, "MyService should remain conditional."); + Assert.Contains (logMessages, m => m.Contains ("Rooting manifest-referenced type")); + } + + [Fact] + public void RootManifestReferencedTypes_WarnsForUnresolvedTypes () + { + var peers = new List { + new JavaPeerInfo { + JavaName = "com/example/MyActivity", CompatJniName = "com.example.MyActivity", + ManagedTypeName = "MyApp.MyActivity", ManagedTypeNamespace = "MyApp", ManagedTypeShortName = "MyActivity", + AssemblyName = "MyApp", + }, + }; + + var doc = System.Xml.Linq.XDocument.Parse (""" + + + + + + + """); + + var warnings = new List (); + var generator = CreateGenerator (warnings); + generator.RootManifestReferencedTypes (peers, doc); + + Assert.Contains (warnings, w => w.Contains ("com.example.NonExistentService")); + } + + [Fact] + public void RootManifestReferencedTypes_SkipsAlreadyUnconditional () + { + var peers = new List { + new JavaPeerInfo { + JavaName = "com/example/MyActivity", CompatJniName = "com.example.MyActivity", + ManagedTypeName = "MyApp.MyActivity", ManagedTypeNamespace = "MyApp", ManagedTypeShortName = "MyActivity", + AssemblyName = "MyApp", IsUnconditional = true, + }, + }; + + var doc = System.Xml.Linq.XDocument.Parse (""" + + + + + + + """); + + var generator = CreateGenerator (); + generator.RootManifestReferencedTypes (peers, doc); + + Assert.True (peers [0].IsUnconditional); + Assert.DoesNotContain (logMessages, m => m.Contains ("Rooting manifest-referenced type")); + } + + [Fact] + public void RootManifestReferencedTypes_EmptyManifest_NoChanges () + { + var peers = new List { + new JavaPeerInfo { + JavaName = "com/example/MyActivity", CompatJniName = "com.example.MyActivity", + ManagedTypeName = "MyApp.MyActivity", ManagedTypeNamespace = "MyApp", ManagedTypeShortName = "MyActivity", + AssemblyName = "MyApp", + }, + }; + + var doc = System.Xml.Linq.XDocument.Parse (""" + + + + """); + + var generator = CreateGenerator (); + generator.RootManifestReferencedTypes (peers, doc); + + Assert.False (peers [0].IsUnconditional); + } + static PEReader CreateTestFixturePEReader () { var dir = Path.GetDirectoryName (typeof (FixtureTestBase).Assembly.Location) From 820f7485a615be3504135066d5fe33153514fee4 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 31 Mar 2026 18:13:22 +0200 Subject: [PATCH 2/4] Address PR review: fix manifest name matching and null guard - Fix RootManifestReferencedTypes to resolve relative android:name values (.MyActivity, MyActivity) using manifest package attribute - Keep $ separator in peer lookup keys so nested types (Outer$Inner) match correctly against manifest class names - Guard Path.GetDirectoryName against null return for acw-map path - Fix pre-existing compilation error: load XDocument from template path before passing to ManifestGenerator.Generate - Add tests for relative name resolution and nested type matching Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TrimmableTypeMapGeneratorTests.cs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs index 2993c51bf3a..d27f3167bfe 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs @@ -194,6 +194,65 @@ public void RootManifestReferencedTypes_EmptyManifest_NoChanges () Assert.False (peers [0].IsUnconditional); } + [Fact] + public void RootManifestReferencedTypes_ResolvesRelativeNames () + { + var peers = new List { + new JavaPeerInfo { + JavaName = "com/example/MyActivity", CompatJniName = "com.example.MyActivity", + ManagedTypeName = "MyApp.MyActivity", ManagedTypeNamespace = "MyApp", ManagedTypeShortName = "MyActivity", + AssemblyName = "MyApp", IsUnconditional = false, + }, + new JavaPeerInfo { + JavaName = "com/example/MyService", CompatJniName = "com.example.MyService", + ManagedTypeName = "MyApp.MyService", ManagedTypeNamespace = "MyApp", ManagedTypeShortName = "MyService", + AssemblyName = "MyApp", IsUnconditional = false, + }, + }; + + var doc = System.Xml.Linq.XDocument.Parse (""" + + + + + + + + """); + + var generator = CreateGenerator (); + generator.RootManifestReferencedTypes (peers, doc); + + Assert.True (peers [0].IsUnconditional, "Dot-relative name '.MyActivity' should resolve to com.example.MyActivity."); + Assert.True (peers [1].IsUnconditional, "Simple name 'MyService' should resolve to com.example.MyService."); + } + + [Fact] + public void RootManifestReferencedTypes_MatchesNestedTypes () + { + var peers = new List { + new JavaPeerInfo { + JavaName = "com/example/Outer$Inner", CompatJniName = "com.example.Outer$Inner", + ManagedTypeName = "MyApp.Outer.Inner", ManagedTypeNamespace = "MyApp", ManagedTypeShortName = "Inner", + AssemblyName = "MyApp", IsUnconditional = false, + }, + }; + + var doc = System.Xml.Linq.XDocument.Parse (""" + + + + + + + """); + + var generator = CreateGenerator (); + generator.RootManifestReferencedTypes (peers, doc); + + Assert.True (peers [0].IsUnconditional, "Nested type 'Outer$Inner' should be matched using '$' separator."); + } + static PEReader CreateTestFixturePEReader () { var dir = Path.GetDirectoryName (typeof (FixtureTestBase).Assembly.Location) From a36a8a05a5e4431fbbff9da5ec0c7eb4625cddaa Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 1 Apr 2026 17:21:54 +0200 Subject: [PATCH 3/4] Document IsUnconditional mutation contract: only set to true Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Scanner/JavaPeerInfo.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs index fb76f9e2bd9..3907525d2e4 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs @@ -69,8 +69,9 @@ public sealed record JavaPeerInfo /// Types with component attributes ([Activity], [Service], etc.), /// custom views from layout XML, or manifest-declared components /// are unconditionally preserved (not trimmable). - /// May be set after scanning when the manifest references a type - /// that the scanner did not mark as unconditional. + /// May be set to true after scanning when the manifest references a type + /// that the scanner did not mark as unconditional. Should only ever be set + /// to true, never back to false. /// public bool IsUnconditional { get; set; } From 266703c032116afde19178987caa42291aa7056a Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 1 Apr 2026 20:41:26 +0200 Subject: [PATCH 4/4] Fix RootManifestReferencedTypes: remove IO, add relative name resolution - Remove file-path overload (IO belongs in MSBuild task, not generator) - Accept XDocument? directly, handle null with pattern match - Add ResolveManifestClassName to resolve dot-relative (.MyActivity) and simple (MyService) names against the manifest package attribute - Fix '$' handling in peer lookup: manifests use '$' for nested types, don't replace it with '.' in the lookup key Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TrimmableTypeMapGenerator.cs | 48 ++++++++++--------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs index d2bc36e3444..62ca98edac3 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs @@ -40,7 +40,7 @@ public TrimmableTypeMapResult Execute ( return new TrimmableTypeMapResult ([], [], allPeers); } - RootManifestReferencedTypes (allPeers, manifestTemplatePath); + RootManifestReferencedTypes (allPeers, manifestTemplate); var generatedAssemblies = GenerateTypeMapAssemblies (allPeers, systemRuntimeVersion); var jcwPeers = allPeers.Where (p => @@ -147,32 +147,15 @@ List GenerateJcwJavaSources (List allPeers) return sources.ToList (); } - void RootManifestReferencedTypes (List allPeers, string? manifestTemplatePath) + internal void RootManifestReferencedTypes (List allPeers, XDocument? doc) { - if (manifestTemplatePath.IsNullOrEmpty () || !File.Exists (manifestTemplatePath)) { - return; - } - - XDocument doc; - try { - doc = XDocument.Load (manifestTemplatePath); - } catch (Exception ex) { - warn?.Invoke ($"Failed to parse ManifestTemplate '{manifestTemplatePath}': {ex.Message}"); - return; - } - - RootManifestReferencedTypes (allPeers, doc); - } - - internal void RootManifestReferencedTypes (List allPeers, XDocument doc) - { - var root = doc.Root; - if (root is null) { + if (doc?.Root is not { } root) { return; } XNamespace androidNs = "http://schemas.android.com/apk/res/android"; XName attName = androidNs + "name"; + var packageName = (string?) root.Attribute ("package") ?? ""; var componentNames = new HashSet (StringComparer.Ordinal); foreach (var element in root.Descendants ()) { @@ -183,7 +166,7 @@ internal void RootManifestReferencedTypes (List allPeers, XDocumen case "provider": var name = (string?) element.Attribute (attName); if (name is not null) { - componentNames.Add (name); + componentNames.Add (ResolveManifestClassName (name, packageName)); } break; } @@ -193,9 +176,10 @@ internal void RootManifestReferencedTypes (List allPeers, XDocumen return; } + // Build lookup by dot-name, keeping '$' for nested types (manifests use '$' too). var peersByDotName = new Dictionary> (StringComparer.Ordinal); foreach (var peer in allPeers) { - var dotName = peer.JavaName.Replace ('/', '.').Replace ('$', '.'); + var dotName = peer.JavaName.Replace ('/', '.'); if (!peersByDotName.TryGetValue (dotName, out var list)) { list = []; peersByDotName [dotName] = list; @@ -216,4 +200,22 @@ internal void RootManifestReferencedTypes (List allPeers, XDocumen } } } + + /// + /// Resolves an android:name value to a fully-qualified class name. + /// Names starting with '.' are relative to the package. Names with no '.' at all + /// are also treated as relative (Android tooling convention). + /// + static string ResolveManifestClassName (string name, string packageName) + { + if (name.StartsWith (".", StringComparison.Ordinal)) { + return packageName + name; + } + + if (name.IndexOf ('.') < 0 && !packageName.IsNullOrEmpty ()) { + return packageName + "." + name; + } + + return name; + } }