From 3b2d707ae264068f762e818a604ef88ed75ea560 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 9 Apr 2026 10:10:15 +0200 Subject: [PATCH 1/2] [TrimmableTypeMap] Manifest rooting and deferred registration propagation - Manifest name promotion: promote compat JNI names for manifest-rooted components so the generated JCW class name matches the manifest entry - Deferred registration propagation: propagate CannotRegisterInStaticConstructor to generated managed base types of Application/Instrumentation - Instrumentation targetPackage: pass package name for manifest instrumentation elements - XA4250 warning: warn when manifest references a type that cannot be resolved in any scanned assembly - NestedAssembly for UTF-8 helpers: fix FieldAccessException from NestedPrivate visibility Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Documentation/docs-mobile/messages/index.md | 1 + Documentation/docs-mobile/messages/xa4250.md | 33 +++ .../Generator/ComponentElementBuilder.cs | 5 +- .../Generator/ManifestGenerator.cs | 13 +- .../ITrimmableTypeMapLogger.cs | 4 + .../Scanner/JavaPeerInfo.cs | 21 +- .../TrimmableTypeMapGenerator.cs | 229 +++++++++++++++- .../Properties/Resources.Designer.cs | 9 + .../Properties/Resources.resx | 5 + .../Tasks/GenerateTrimmableTypeMap.cs | 4 + .../Generator/ManifestGeneratorTests.cs | 18 ++ .../TrimmableTypeMapGeneratorTests.cs | 248 +++++++++++++++++- 12 files changed, 575 insertions(+), 15 deletions(-) create mode 100644 Documentation/docs-mobile/messages/xa4250.md diff --git a/Documentation/docs-mobile/messages/index.md b/Documentation/docs-mobile/messages/index.md index 0ef4f1febfa..ae77f286553 100644 --- a/Documentation/docs-mobile/messages/index.md +++ b/Documentation/docs-mobile/messages/index.md @@ -207,6 +207,7 @@ Either change the value in the AndroidManifest.xml to match the $(SupportedOSPla + [XA4247](xa4247.md): Could not resolve POM file for artifact '{artifact}'. + [XA4248](xa4248.md): Could not find NuGet package '{nugetId}' version '{version}' in lock file. Ensure NuGet Restore has run since this `` was added. + [XA4235](xa4249.md): Maven artifact specification '{artifact}' is invalid. The correct format is 'group_id:artifact_id:version'. ++ [XA4250](xa4250.md): Manifest-referenced type '{type}' was not found in any scanned assembly. It may be a framework type. + XA4300: Native library '{library}' will not be bundled because it has an unsupported ABI. + [XA4301](xa4301.md): Apk already contains the item `xxx`. + [XA4302](xa4302.md): Unhandled exception merging \`AndroidManifest.xml\`: {ex} diff --git a/Documentation/docs-mobile/messages/xa4250.md b/Documentation/docs-mobile/messages/xa4250.md new file mode 100644 index 00000000000..3ef74ddc5e5 --- /dev/null +++ b/Documentation/docs-mobile/messages/xa4250.md @@ -0,0 +1,33 @@ +--- +title: .NET for Android warning XA4250 +description: XA4250 warning code +ms.date: 04/07/2026 +f1_keywords: + - "XA4250" +--- + +# .NET for Android warning XA4250 + +## Example message + +Manifest-referenced type '{0}' was not found in any scanned assembly. It may be a framework type. + +```text +warning XA4250: Manifest-referenced type 'com.example.MainActivity' was not found in any scanned assembly. It may be a framework type. +``` + +## Issue + +The build found a type name in `AndroidManifest.xml`, but it could not match that name to any Java peer discovered in the app's managed assemblies. + +This can be expected for framework-provided types, but it can also indicate that the manifest entry does not match the name generated for a managed Android component. + +## Solution + +If the manifest entry refers to an Android framework type, this warning can usually be ignored. + +Otherwise: + +1. Verify the `android:name` value in the manifest. +2. Ensure the managed type is included in the app build. +3. Check for namespace, `[Register]`, or nested-type naming mismatches between the manifest and the managed type. diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ComponentElementBuilder.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ComponentElementBuilder.cs index e382556c748..6745e9b2b2e 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ComponentElementBuilder.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ComponentElementBuilder.cs @@ -165,7 +165,7 @@ internal static void UpdateApplicationElement (XElement app, JavaPeerInfo peer) PropertyMapper.ApplyMappings (app, component.Properties, PropertyMapper.ApplicationElementMappings); } - internal static void AddInstrumentation (XElement manifest, JavaPeerInfo peer) + internal static void AddInstrumentation (XElement manifest, JavaPeerInfo peer, string packageName) { string jniName = JniSignatureHelper.JniNameToJavaName (peer.JavaName); var element = new XElement ("instrumentation", @@ -176,6 +176,9 @@ internal static void AddInstrumentation (XElement manifest, JavaPeerInfo peer) return; } PropertyMapper.ApplyMappings (element, component.Properties, PropertyMapper.InstrumentationMappings); + if (element.Attribute (AndroidNs + "targetPackage") is null && !string.IsNullOrEmpty (packageName)) { + element.SetAttributeValue (AndroidNs + "targetPackage", packageName); + } manifest.Add (element); } diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ManifestGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ManifestGenerator.cs index 27806a679bd..9f64a305023 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ManifestGenerator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ManifestGenerator.cs @@ -73,7 +73,7 @@ class ManifestGenerator } if (peer.ComponentAttribute.Kind == ComponentKind.Instrumentation) { - ComponentElementBuilder.AddInstrumentation (manifest, peer); + ComponentElementBuilder.AddInstrumentation (manifest, peer, PackageName); continue; } @@ -116,10 +116,7 @@ class ManifestGenerator } // Apply manifest placeholders - string? placeholders = ManifestPlaceholders; - if (placeholders is not null && placeholders.Length > 0) { - ApplyPlaceholders (doc, placeholders); - } + ApplyPlaceholders (doc, ManifestPlaceholders); return (doc, providerNames); } @@ -250,8 +247,12 @@ XElement CreateRuntimeProvider (string name, string? processName, int initOrder) /// Replaces ${key} placeholders in all attribute values throughout the document. /// Placeholder format: "key1=value1;key2=value2" /// - static void ApplyPlaceholders (XDocument doc, string placeholders) + internal static void ApplyPlaceholders (XDocument doc, string? placeholders) { + if (placeholders.IsNullOrEmpty ()) { + return; + } + var replacements = new Dictionary (StringComparer.Ordinal); foreach (var entry in placeholders.Split (PlaceholderSeparators, StringSplitOptions.RemoveEmptyEntries)) { var eqIndex = entry.IndexOf ('='); diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/ITrimmableTypeMapLogger.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/ITrimmableTypeMapLogger.cs index ea1cd664a96..e7322ce112f 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/ITrimmableTypeMapLogger.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/ITrimmableTypeMapLogger.cs @@ -10,4 +10,8 @@ public interface ITrimmableTypeMapLogger void LogGeneratedRootTypeMapInfo (int assemblyReferenceCount); void LogGeneratedTypeMapAssembliesInfo (int assemblyCount); void LogGeneratedJcwFilesInfo (int sourceCount); + + void LogUnresolvedTypeWarning (string name); + + void LogRootingManifestReferencedTypeInfo (string name, string managedTypeName); } diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs index aed62453042..ad4c6b5178c 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs @@ -12,9 +12,11 @@ public sealed record JavaPeerInfo { /// /// JNI type name, e.g., "android/app/Activity". - /// Extracted from the [Register] attribute. + /// Extracted from the [Register] attribute or auto-computed during scanning. + /// Manifest rooting may later promote this to when + /// a component is referenced by its managed-namespace form. /// - public required string JavaName { get; init; } + public required string JavaName { get; set; } /// /// Compat JNI type name, e.g., "myapp.namespace/MyType" for user types (uses raw namespace, not CRC64). @@ -48,7 +50,7 @@ public sealed record JavaPeerInfo /// that extends Activity. Null for java/lang/Object or types without a Java base. /// Needed by JCW Java source generation ("extends" clause). /// - public string? BaseJavaName { get; init; } + public string? BaseJavaName { get; set; } /// /// JNI names of Java interfaces this type implements, e.g., ["android/view/View$OnClickListener"]. @@ -69,16 +71,23 @@ 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 + /// True for Application and Instrumentation types, plus any generated managed + /// base classes they rely on during startup. These types cannot call /// registerNatives in their static initializer because the native library /// (libmonodroid.so) is not loaded until after the Application class is instantiated. /// Registration is deferred to ApplicationRegistration.registerApplications(). + /// This may also be set after scanning when a type is only discovered from + /// manifest android:name usage on <application> or + /// <instrumentation>. /// - public bool CannotRegisterInStaticConstructor { get; init; } + public bool CannotRegisterInStaticConstructor { get; set; } /// /// Marshal methods: methods with [Register(name, sig, connector)], [Export], or diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs index 0697f31ea54..c4dc7c24fa0 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs @@ -38,6 +38,9 @@ public TrimmableTypeMapResult Execute ( return new TrimmableTypeMapResult ([], [], allPeers); } + RootManifestReferencedTypes (allPeers, PrepareManifestForRooting (manifestTemplate, manifestConfig)); + PropagateDeferredRegistrationToManagedBaseTypes (allPeers); + var generatedAssemblies = GenerateTypeMapAssemblies (allPeers, systemRuntimeVersion); var jcwPeers = allPeers.Where (p => !frameworkAssemblyNames.Contains (p.AssemblyName) @@ -47,8 +50,9 @@ public TrimmableTypeMapResult Execute ( // Collect Application/Instrumentation types that need deferred registerNatives var appRegTypes = allPeers - .Where (p => p.CannotRegisterInStaticConstructor && !p.IsAbstract) + .Where (p => p.CannotRegisterInStaticConstructor && !p.DoNotGenerateAcw) .Select (p => JniSignatureHelper.JniNameToJavaName (p.JavaName)) + .Distinct (StringComparer.Ordinal) .ToList (); if (appRegTypes.Count > 0) { logger.LogDeferredRegistrationTypesInfo (appRegTypes.Count); @@ -139,4 +143,227 @@ List GenerateJcwJavaSources (List allPeers) logger.LogGeneratedJcwFilesInfo (sources.Count); 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); + var deferredRegistrationNames = new HashSet (StringComparer.Ordinal); + foreach (var element in root.Descendants ()) { + switch (element.Name.LocalName) { + case "application": + case "activity": + case "instrumentation": + case "service": + case "receiver": + case "provider": + var name = (string?) element.Attribute (attName); + if (name is not null) { + var resolvedName = ResolveManifestClassName (name, packageName); + componentNames.Add (resolvedName); + + if (element.Name.LocalName is "application" or "instrumentation") { + deferredRegistrationNames.Add (resolvedName); + } + } + break; + } + } + + if (componentNames.Count == 0) { + return; + } + + // Build lookup by both Java and compat dot-names. Keep '$' for nested types, + // because manifests commonly use '$', but also include the Java source form. + var peersByDotName = new Dictionary> (StringComparer.Ordinal); + foreach (var peer in allPeers) { + AddJniLookupNames (peersByDotName, peer.JavaName, peer); + + if (peer.CompatJniName != peer.JavaName) { + AddJniLookupNames (peersByDotName, peer.CompatJniName, peer); + } + } + + foreach (var name in componentNames) { + if (peersByDotName.TryGetValue (name, out var peers)) { + foreach (var peer in peers.Distinct ()) { + PromoteManifestCompatibleJavaName (allPeers, peer, name); + + if (deferredRegistrationNames.Contains (name)) { + peer.CannotRegisterInStaticConstructor = true; + } + + if (!peer.IsUnconditional) { + peer.IsUnconditional = true; + logger.LogRootingManifestReferencedTypeInfo (name, peer.ManagedTypeName); + } + } + } else { + logger.LogUnresolvedTypeWarning (name); + } + } + } + + static void PromoteManifestCompatibleJavaName (List allPeers, JavaPeerInfo peer, string manifestName) + { + if (peer.JavaName == peer.CompatJniName || !MatchesManifestName (peer.CompatJniName, manifestName)) { + return; + } + + var promotedJavaName = NormalizeJniName (peer.CompatJniName); + var previousJavaName = peer.JavaName; + if (promotedJavaName == previousJavaName) { + return; + } + + peer.JavaName = promotedJavaName; + + foreach (var candidate in allPeers) { + if (candidate.BaseJavaName == previousJavaName) { + candidate.BaseJavaName = promotedJavaName; + } + } + } + + void PropagateDeferredRegistrationToManagedBaseTypes (List allPeers) + { + var peersByJavaName = new Dictionary (StringComparer.Ordinal); + foreach (var peer in allPeers) { + if (!peersByJavaName.ContainsKey (peer.JavaName)) { + peersByJavaName.Add (peer.JavaName, peer); + } + if (peer.CompatJniName != peer.JavaName && !peersByJavaName.ContainsKey (peer.CompatJniName)) { + peersByJavaName.Add (peer.CompatJniName, peer); + } + } + + foreach (var peer in allPeers.Where (p => p.CannotRegisterInStaticConstructor)) { + PropagateDeferredRegistrationToManagedBaseTypes (peer, peersByJavaName); + } + } + + void PropagateDeferredRegistrationToManagedBaseTypes (JavaPeerInfo peer, Dictionary peersByJavaName) + { + var visited = new HashSet (StringComparer.Ordinal); + var baseJavaName = peer.BaseJavaName; + + while (!baseJavaName.IsNullOrEmpty () && visited.Add (baseJavaName)) { + if (!peersByJavaName.TryGetValue (baseJavaName, out var basePeer) || basePeer.DoNotGenerateAcw) { + break; + } + + basePeer.CannotRegisterInStaticConstructor = true; + basePeer.IsUnconditional = true; + + baseJavaName = basePeer.BaseJavaName; + } + } + + static void AddPeerByDotName (Dictionary> peersByDotName, string dotName, JavaPeerInfo peer) + { + if (!peersByDotName.TryGetValue (dotName, out var list)) { + list = []; + peersByDotName [dotName] = list; + } + + list.Add (peer); + } + + static XDocument? PrepareManifestForRooting (XDocument? manifestTemplate, ManifestConfig? manifestConfig) + { + if (manifestTemplate is null && manifestConfig is null) { + return null; + } + + var doc = manifestTemplate is not null + ? new XDocument (manifestTemplate) + : new XDocument ( + new XElement ( + "manifest", + new XAttribute (XNamespace.Xmlns + "android", ManifestConstants.AndroidNs.NamespaceName))); + + if (doc.Root is not { } root) { + return doc; + } + + if (manifestConfig is null) { + return doc; + } + + if (((string?) root.Attribute ("package")).IsNullOrEmpty () && !manifestConfig.PackageName.IsNullOrEmpty ()) { + root.SetAttributeValue ("package", manifestConfig.PackageName); + } + + ManifestGenerator.ApplyPlaceholders (doc, manifestConfig.ManifestPlaceholders); + + if (!manifestConfig.ApplicationJavaClass.IsNullOrEmpty ()) { + var app = root.Element ("application"); + if (app is null) { + app = new XElement ("application"); + root.Add (app); + } + + if (app.Attribute (ManifestConstants.AttName) is null) { + app.SetAttributeValue (ManifestConstants.AttName, manifestConfig.ApplicationJavaClass); + } + } + + return doc; +} + + static bool MatchesManifestName (string jniOrJavaName, string manifestName) + { + var normalizedName = NormalizeJniName (jniOrJavaName); + var simpleName = JniSignatureHelper.GetJavaSimpleName (normalizedName); + var packageName = JniSignatureHelper.GetJavaPackageName (normalizedName); + var manifestStyleName = packageName.IsNullOrEmpty () ? simpleName : packageName + "." + simpleName; + + return manifestStyleName == manifestName || JniSignatureHelper.JniNameToJavaName (normalizedName) == manifestName; + } + + static string NormalizeJniName (string jniOrJavaName) + { + return jniOrJavaName.IndexOf ('/') >= 0 + ? jniOrJavaName + : jniOrJavaName.Replace ('.', '/'); + } + + static void AddJniLookupNames (Dictionary> peersByDotName, string jniName, JavaPeerInfo peer) + { + var simpleName = JniSignatureHelper.GetJavaSimpleName (jniName); + var packageName = JniSignatureHelper.GetJavaPackageName (jniName); + var manifestName = packageName.IsNullOrEmpty () ? simpleName : packageName + "." + simpleName; + AddPeerByDotName (peersByDotName, manifestName, peer); + + var javaSourceName = JniSignatureHelper.JniNameToJavaName (jniName); + if (javaSourceName != manifestName) { + AddPeerByDotName (peersByDotName, javaSourceName, peer); + } + } + + /// + /// 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/Properties/Resources.Designer.cs b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs index bc2d9a7d4d4..c278679f4ee 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs @@ -1427,6 +1427,15 @@ public static string XA4249 { } } + /// + /// Looks up a localized string similar to Manifest-referenced type '{0}' was not found in any scanned assembly. It may be a framework type.. + /// + public static string XA4250 { + get { + return ResourceManager.GetString("XA4250", resourceCulture); + } + } + /// /// Looks up a localized string similar to Native library '{0}' will not be bundled because it has an unsupported ABI. Move this file to a directory with a valid Android ABI name such as 'libs/armeabi-v7a/'.. /// diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx index d1ee02dc051..ca75ec196de 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx @@ -1068,6 +1068,11 @@ To use a custom JDK path for a command line build, set the 'JavaSdkDirectory' MS Maven artifact specification '{0}' is invalid. The correct format is 'group_id:artifact_id:version'. The following are literal names and should not be translated: Maven, group_id, artifact_id {0} - A Maven artifact specification + + + Manifest-referenced type '{0}' was not found in any scanned assembly. It may be a framework type. + The following are literal names and should not be translated: Manifest, framework. +{0} - Java type name from AndroidManifest.xml Command '{0}' failed.\n{1} diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs index 715eac0b495..c4cb4d2d2b3 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs @@ -33,6 +33,10 @@ public void LogGeneratedTypeMapAssembliesInfo (int assemblyCount) => log.LogMessage (MessageImportance.Low, $"Generated {assemblyCount} typemap assemblies."); public void LogGeneratedJcwFilesInfo (int sourceCount) => log.LogMessage (MessageImportance.Low, $"Generated {sourceCount} JCW Java source files."); + public void LogUnresolvedTypeWarning (string name) => + log.LogCodedWarning ("XA4250", Properties.Resources.XA4250, name); + public void LogRootingManifestReferencedTypeInfo (string name, string managedTypeName) => + log.LogMessage (MessageImportance.Low, $"Rooting manifest-referenced type '{name}' ({managedTypeName}) as unconditional."); } public override string TaskPrefix => "GTT"; diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ManifestGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ManifestGeneratorTests.cs index 07929a9d5a4..a3e7904f654 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ManifestGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ManifestGeneratorTests.cs @@ -253,6 +253,24 @@ public void Instrumentation_GoesToManifest () Assert.Null (appInstrumentation); } + [Fact] + public void Instrumentation_DefaultsTargetPackageToManifestPackage () + { + var gen = CreateDefaultGenerator (); + var peer = CreatePeer ("com/example/app/MyInstrumentation", new ComponentInfo { + Kind = ComponentKind.Instrumentation, + Properties = new Dictionary { + ["Label"] = "My Test", + }, + }); + + var doc = GenerateAndLoad (gen, [peer]); + var instrumentation = doc.Root?.Element ("instrumentation"); + + Assert.NotNull (instrumentation); + Assert.Equal ("com.example.app", (string?)instrumentation?.Attribute (AndroidNs + "targetPackage")); + } + [Fact] public void RuntimeProvider_Added () { diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs index c07340ae981..67faf262f3f 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs @@ -12,7 +12,7 @@ public class TrimmableTypeMapGeneratorTests : FixtureTestBase { readonly List logMessages = new (); - sealed class TestTrimmableTypeMapLogger (List logMessages) : ITrimmableTypeMapLogger + sealed class TestTrimmableTypeMapLogger (List logMessages, List? warnings = null) : ITrimmableTypeMapLogger { public void LogNoJavaPeerTypesFound () => logMessages.Add ("No Java peer types found, skipping typemap generation."); @@ -30,6 +30,10 @@ public void LogGeneratedTypeMapAssembliesInfo (int assemblyCount) => logMessages.Add ($"Generated {assemblyCount} typemap assemblies."); public void LogGeneratedJcwFilesInfo (int sourceCount) => logMessages.Add ($"Generated {sourceCount} JCW Java source files."); + public void LogUnresolvedTypeWarning (string name) => + warnings?.Add (name); + public void LogRootingManifestReferencedTypeInfo (string name, string managedTypeName) => + logMessages.Add ($"Rooting manifest-referenced type '{name}' ({managedTypeName}) as unconditional."); } [Fact] @@ -99,8 +103,250 @@ public void Execute_JavaSourcesHaveCorrectStructure () Assert.Contains ("class ", source.Content); } + [Fact] + public void Execute_ManifestPlaceholdersAreResolvedBeforeRooting () + { + using var peReader = CreateTestFixturePEReader (); + var manifestTemplate = System.Xml.Linq.XDocument.Parse (""" + + + + + + + """); + + var result = CreateGenerator ().Execute ( + new List<(string, PEReader)> { ("TestFixtures", peReader) }, + new Version (11, 0), + new HashSet (), + new ManifestConfig ( + PackageName: "my.app", + AndroidApiLevel: "35", + SupportedOSPlatformVersion: "21", + RuntimeProviderJavaName: "mono.MonoRuntimeProvider", + ManifestPlaceholders: "applicationId=my.app"), + manifestTemplate); + + var peer = result.AllPeers.First (p => p.ManagedTypeName == "MyApp.SimpleActivity"); + Assert.True (peer.IsUnconditional, "Relative manifest names should root correctly after placeholder substitution."); + } + TrimmableTypeMapGenerator CreateGenerator () => new (new TestTrimmableTypeMapLogger (logMessages)); + TrimmableTypeMapGenerator CreateGenerator (List warnings) => + new (new TestTrimmableTypeMapLogger (logMessages, warnings)); + + [Theory] + [InlineData ("com/example/MyActivity", "com.example.MyActivity", "com.example", "activity", "com.example.MyActivity")] + [InlineData ("com/example/MyActivity", "com.example.MyActivity", "com.example", "activity", ".MyActivity")] + [InlineData ("com/example/MyService", "com.example.MyService", "com.example", "service", "MyService")] + [InlineData ("crc64123456789abc/MyActivity", "my/app/MyActivity", "my.app", "activity", ".MyActivity")] + [InlineData ("com/example/Outer$Inner", "com.example.Outer$Inner", "com.example", "activity", "com.example.Outer$Inner")] + public void RootManifestReferencedTypes_RootsManifestReferencedTypes ( + string javaName, + string compatJniName, + string packageName, + string elementName, + string manifestName) + { + var peers = new List { + new JavaPeerInfo { + JavaName = javaName, CompatJniName = compatJniName, + ManagedTypeName = "MyApp.MyTarget", ManagedTypeNamespace = "MyApp", ManagedTypeShortName = "MyTarget", + AssemblyName = "MyApp", IsUnconditional = false, + }, + new JavaPeerInfo { + JavaName = "com/example/OtherType", CompatJniName = "com.example.OtherType", + ManagedTypeName = "MyApp.OtherType", ManagedTypeNamespace = "MyApp", ManagedTypeShortName = "OtherType", + AssemblyName = "MyApp", IsUnconditional = false, + }, + }; + + var doc = System.Xml.Linq.XDocument.Parse ($$""" + + + + <{{elementName}} android:name="{{manifestName}}" /> + + + """); + + var generator = CreateGenerator (); + generator.RootManifestReferencedTypes (peers, doc); + + Assert.True (peers [0].IsUnconditional, "The manifest-referenced type should be rooted as unconditional."); + Assert.False (peers [1].IsUnconditional, "Non-matching peers should remain conditional."); + Assert.Contains (logMessages, m => m.Contains ("Rooting manifest-referenced type")); + } + + [Fact] + public void RootManifestReferencedTypes_RootsApplicationAndInstrumentationTypes () + { + var peers = new List { + new JavaPeerInfo { + JavaName = "com/example/MyApplication", CompatJniName = "com.example.MyApplication", + ManagedTypeName = "MyApp.MyApplication", ManagedTypeNamespace = "MyApp", ManagedTypeShortName = "MyApplication", + AssemblyName = "MyApp", IsUnconditional = false, + }, + new JavaPeerInfo { + JavaName = "com/example/MyInstrumentation", CompatJniName = "com.example.MyInstrumentation", + ManagedTypeName = "MyApp.MyInstrumentation", ManagedTypeNamespace = "MyApp", ManagedTypeShortName = "MyInstrumentation", + AssemblyName = "MyApp", IsUnconditional = false, + }, + }; + + var doc = System.Xml.Linq.XDocument.Parse (""" + + + + + + """); + + var generator = CreateGenerator (); + generator.RootManifestReferencedTypes (peers, doc); + + Assert.True (peers [0].IsUnconditional, "Application type should be rooted from ."); + Assert.True (peers [1].IsUnconditional, "Instrumentation type should be rooted from ."); + Assert.True (peers [0].CannotRegisterInStaticConstructor, "Application type should defer Runtime.registerNatives()."); + Assert.True (peers [1].CannotRegisterInStaticConstructor, "Instrumentation type should defer Runtime.registerNatives()."); + } + + [Fact] + public void RootManifestReferencedTypes_PromotesCompatJniNameForManifestDeclaredApplication () + { + var peers = new List { + new JavaPeerInfo { + JavaName = "crc64123456789abc/MyApplication", CompatJniName = "my/app/MyApplication", + ManagedTypeName = "MyApp.MyApplication", ManagedTypeNamespace = "MyApp", ManagedTypeShortName = "MyApplication", + AssemblyName = "MyApp", BaseJavaName = "android/app/Application", IsUnconditional = false, + }, + new JavaPeerInfo { + JavaName = "crc64123456789abc/MyDerivedApplication", CompatJniName = "my/app/MyDerivedApplication", + ManagedTypeName = "MyApp.MyDerivedApplication", ManagedTypeNamespace = "MyApp", ManagedTypeShortName = "MyDerivedApplication", + AssemblyName = "MyApp", BaseJavaName = "crc64123456789abc/MyApplication", IsUnconditional = false, + }, + }; + + var doc = System.Xml.Linq.XDocument.Parse (""" + + + + + """); + + var generator = CreateGenerator (); + generator.RootManifestReferencedTypes (peers, doc); + + Assert.Equal ("my/app/MyApplication", peers [0].JavaName); + Assert.Equal ("my/app/MyApplication", peers [1].BaseJavaName); + Assert.True (peers [0].CannotRegisterInStaticConstructor); + } + + [Fact] + public void Execute_DeferredRegistrationIncludesManagedBaseHierarchy () + { + using var peReader = CreateTestFixturePEReader (); + var result = CreateGenerator ().Execute ( + new List<(string, PEReader)> { ("TestFixtures", peReader) }, + new Version (11, 0), + new HashSet ()); + + Assert.Contains ("my.app.BaseApplication", result.ApplicationRegistrationTypes); + Assert.Contains ("my.app.MyApplication", result.ApplicationRegistrationTypes); + Assert.Contains ("my.app.BaseInstrumentation", result.ApplicationRegistrationTypes); + Assert.Contains ("my.app.IntermediateInstrumentation", result.ApplicationRegistrationTypes); + Assert.Contains ("my.app.MyInstrumentation", result.ApplicationRegistrationTypes); + + var baseInstrumentation = result.GeneratedJavaSources.Single (s => s.RelativePath == "my/app/BaseInstrumentation.java"); + var intermediateInstrumentation = result.GeneratedJavaSources.Single (s => s.RelativePath == "my/app/IntermediateInstrumentation.java"); + + Assert.DoesNotContain ("static {", baseInstrumentation.Content); + Assert.DoesNotContain ("static {", intermediateInstrumentation.Content); + Assert.Contains ("private static synchronized void __md_registerNatives ()", baseInstrumentation.Content); + Assert.Contains ("private static synchronized void __md_registerNatives ()", intermediateInstrumentation.Content); + Assert.DoesNotContain ("if (getClass () == BaseInstrumentation.class) nctor_0 ();", baseInstrumentation.Content); + Assert.DoesNotContain ("if (getClass () == IntermediateInstrumentation.class) nctor_0 ();", intermediateInstrumentation.Content); + } + + [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 ca19ca120bdb7657f1e928e75f200902ca1a6c29 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 9 Apr 2026 17:13:06 +0200 Subject: [PATCH 2/2] [TrimmableTypeMap] Address review feedback on manifest rooting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generator/ComponentElementBuilder.cs | 2 +- .../ITrimmableTypeMapLogger.cs | 2 -- .../TrimmableTypeMapGenerator.cs | 27 ++++++++++++++----- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ComponentElementBuilder.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ComponentElementBuilder.cs index 6745e9b2b2e..f6598cb732f 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ComponentElementBuilder.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ComponentElementBuilder.cs @@ -176,7 +176,7 @@ internal static void AddInstrumentation (XElement manifest, JavaPeerInfo peer, s return; } PropertyMapper.ApplyMappings (element, component.Properties, PropertyMapper.InstrumentationMappings); - if (element.Attribute (AndroidNs + "targetPackage") is null && !string.IsNullOrEmpty (packageName)) { + if (element.Attribute (AndroidNs + "targetPackage") is null && !packageName.IsNullOrEmpty ()) { element.SetAttributeValue (AndroidNs + "targetPackage", packageName); } diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/ITrimmableTypeMapLogger.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/ITrimmableTypeMapLogger.cs index e7322ce112f..c0ea7958539 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/ITrimmableTypeMapLogger.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/ITrimmableTypeMapLogger.cs @@ -10,8 +10,6 @@ public interface ITrimmableTypeMapLogger void LogGeneratedRootTypeMapInfo (int assemblyReferenceCount); void LogGeneratedTypeMapAssembliesInfo (int assemblyCount); void LogGeneratedJcwFilesInfo (int sourceCount); - void LogUnresolvedTypeWarning (string name); - void LogRootingManifestReferencedTypeInfo (string name, string managedTypeName); } diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs index c4dc7c24fa0..2babd3b6a7d 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Reflection.PortableExecutable; +using System.Runtime.CompilerServices; using System.Xml.Linq; namespace Microsoft.Android.Sdk.TrimmableTypeMap; @@ -194,7 +195,12 @@ internal void RootManifestReferencedTypes (List allPeers, XDocumen foreach (var name in componentNames) { if (peersByDotName.TryGetValue (name, out var peers)) { - foreach (var peer in peers.Distinct ()) { + var processedPeers = new HashSet (ReferenceEqualityComparer.Instance); + foreach (var peer in peers) { + if (!processedPeers.Add (peer)) { + continue; + } + PromoteManifestCompatibleJavaName (allPeers, peer, name); if (deferredRegistrationNames.Contains (name)) { @@ -277,6 +283,15 @@ static void AddPeerByDotName (Dictionary> peersByDotN list.Add (peer); } + sealed class ReferenceEqualityComparer : IEqualityComparer where T : class + { + public static ReferenceEqualityComparer Instance { get; } = new (); + + public bool Equals (T? x, T? y) => ReferenceEquals (x, y); + + public int GetHashCode (T obj) => RuntimeHelpers.GetHashCode (obj); + } + static XDocument? PrepareManifestForRooting (XDocument? manifestTemplate, ManifestConfig? manifestConfig) { if (manifestTemplate is null && manifestConfig is null) { @@ -311,13 +326,13 @@ static void AddPeerByDotName (Dictionary> peersByDotN root.Add (app); } - if (app.Attribute (ManifestConstants.AttName) is null) { - app.SetAttributeValue (ManifestConstants.AttName, manifestConfig.ApplicationJavaClass); + if (app.Attribute (ManifestConstants.AttName) is null) { + app.SetAttributeValue (ManifestConstants.AttName, manifestConfig.ApplicationJavaClass); + } } - } - return doc; -} + return doc; + } static bool MatchesManifestName (string jniOrJavaName, string manifestName) {