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 c602e12611b..88eec16cc2b 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs @@ -3,16 +3,19 @@ using System.IO; using System.Linq; using System.Reflection.PortableExecutable; +using System.Xml.Linq; 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 +41,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) @@ -57,7 +62,10 @@ public TrimmableTypeMapResult Execute ( // Write merged acw-map.txt if requested if (!acwMapOutputPath.IsNullOrEmpty ()) { - Directory.CreateDirectory (Path.GetDirectoryName (acwMapOutputPath)); + var acwDirectory = Path.GetDirectoryName (acwMapOutputPath); + if (!acwDirectory.IsNullOrEmpty ()) { + Directory.CreateDirectory (acwDirectory); + } using (var writer = new StreamWriter (acwMapOutputPath)) { AcwMapWriter.Write (writer, allPeers); } @@ -99,7 +107,12 @@ IList GenerateManifest (List allPeers, AssemblyManifestInf ApplicationJavaClass = config.ApplicationJavaClass, }; - return generator.Generate (manifestTemplatePath, allPeers, assemblyManifestInfo, mergedManifestOutputPath); + XDocument? manifestTemplateDoc = null; + if (!manifestTemplatePath.IsNullOrEmpty () && File.Exists (manifestTemplatePath)) { + manifestTemplateDoc = XDocument.Load (manifestTemplatePath); + } + + return generator.Generate (manifestTemplateDoc, allPeers, assemblyManifestInfo, mergedManifestOutputPath); } (List peers, AssemblyManifestInfo manifestInfo) ScanAssemblies (IReadOnlyList<(string Name, PEReader Reader)> assemblies) @@ -144,4 +157,94 @@ 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 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 a6a12ceb8c3..539fb24058d 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs @@ -94,7 +94,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)); result = generator.Execute ( assemblies, systemRuntimeVersion, 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)