diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs
index aed62453042..3907525d2e4 100644
--- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs
+++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs
@@ -69,8 +69,11 @@ 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 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; 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..62ca98edac3 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, manifestTemplate);
+
var generatedAssemblies = GenerateTypeMapAssemblies (allPeers, systemRuntimeVersion);
var jcwPeers = allPeers.Where (p =>
!frameworkAssemblyNames.Contains (p.AssemblyName)
@@ -142,4 +146,76 @@ List GenerateJcwJavaSources (List allPeers)
log ($"Generated {sources.Count} JCW Java source files.");
return sources.ToList ();
}
+
+ internal void RootManifestReferencedTypes (List allPeers, XDocument? doc)
+ {
+ 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 ()) {
+ 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 (ResolveManifestClassName (name, packageName));
+ }
+ break;
+ }
+ }
+
+ if (componentNames.Count == 0) {
+ 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 ('/', '.');
+ 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.");
+ }
+ }
+ }
+
+ ///
+ /// 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;
+ }
}
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..d27f3167bfe 100644
--- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs
+++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs
@@ -81,6 +81,178 @@ 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);
+ }
+
+ [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)