From 8193271d54f7bd358bb3414425bff04166e1bd55 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 24 Aug 2026 22:46:05 +0200 Subject: [PATCH 1/3] Make trimmable typemap ACWs conditional Let ILLink trim ordinary Java peers while preserving explicit external Java roots from attributes, manifests, and custom-view resources. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a6de09d8-fc09-4560-a976-ffcccd21ba70 --- .../Generator/ModelBuilder.cs | 6 - .../Scanner/JavaPeerScanner.cs | 11 +- .../TrimmableTypeMapGenerator.cs | 18 +- ...soft.Android.Sdk.TypeMap.Trimmable.targets | 5 +- .../Tasks/GenerateTrimmableTypeMap.cs | 8 +- .../TrimmableTypeMapBuildTests.cs | 196 ++++++++++++++++++ .../TrimmableTypeMapGeneratorTests.cs | 22 ++ .../Generator/TypeMapModelBuilderTests.cs | 36 ++-- .../Scanner/JavaPeerScannerTests.cs | 2 + 9 files changed, 270 insertions(+), 34 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs index ca25d1962d7..b354e6a1558 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs @@ -235,12 +235,6 @@ static bool IsUnconditionalEntry (JavaPeerInfo peer) return true; } - // User-defined ACW types (not MCW bindings, not interfaces) are unconditional - // because Android can instantiate them from Java at any time. - if (!peer.IsFrameworkAssembly && !peer.DoNotGenerateAcw && !peer.IsInterface) { - return true; - } - // Types marked unconditional by the scanner (component attributes: Activity, Service, etc.) if (peer.IsUnconditional) { return true; diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs index a9b28686132..6a164ce2274 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs @@ -209,9 +209,12 @@ internal AssemblyManifestInfo ScanAssemblyManifestInfo () /// [Application(ManageSpaceActivity = typeof(X))] must be unconditional, /// because the manifest will reference them even if nothing else does. /// - static void ForceUnconditionalCrossReferences (Dictionary<(string ManagedName, string AssemblyName), JavaPeerInfo> results, Dictionary assemblyCache) + void ForceUnconditionalCrossReferences (Dictionary<(string ManagedName, string AssemblyName), JavaPeerInfo> results, Dictionary assemblyCache) { foreach (var index in assemblyCache.Values) { + if (frameworkAssemblyNames.Contains (index.AssemblyName)) { + continue; + } foreach (var attrInfo in index.AttributesByType.Values) { if (attrInfo is ApplicationAttributeInfo applicationAttributeInfo) { ForceUnconditionalIfPresent (results, applicationAttributeInfo.BackupAgent); @@ -343,7 +346,9 @@ void ScanAssembly (AssemblyIndex index, Dictionary<(string ManagedName, string A var isInterface = (typeDef.Attributes & TypeAttributes.Interface) != 0; var isAbstract = (typeDef.Attributes & TypeAttributes.Abstract) != 0; - var isUnconditional = attrInfo is not null; + var isFrameworkAssembly = frameworkAssemblyNames.Contains (index.AssemblyName); + var isUnconditional = !isFrameworkAssembly && + (attrInfo is not null || registerInfo?.IsFromJniTypeSignature == true); var cannotRegisterInStaticConstructor = attrInfo is ApplicationAttributeInfo or InstrumentationAttributeInfo; string? invokerTypeName = null; ActivationCtorStyle? invokerActivationCtorStyle = null; @@ -382,7 +387,7 @@ void ScanAssembly (AssemblyIndex index, Dictionary<(string ManagedName, string A ManagedTypeNamespace = ExtractNamespace (fullName), ManagedTypeShortName = ExtractShortName (fullName), AssemblyName = index.AssemblyName, - IsFrameworkAssembly = frameworkAssemblyNames.Contains (index.AssemblyName), + IsFrameworkAssembly = isFrameworkAssembly, BaseJavaName = baseJavaName, ImplementedInterfaceJavaNames = implementedInterfaces, IsInterface = isInterface, diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs index be49c266d47..0e83ac42df6 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs @@ -35,7 +35,8 @@ public TrimmableTypeMapResult Execute ( XDocument? manifestTemplate = null, string? packageNamingPolicy = null, bool generateTypeMapAssemblies = true, - bool errorOnCustomJavaObject = true) + bool errorOnCustomJavaObject = true, + IReadOnlyCollection? customViewTypeNames = null) { _ = assemblies ?? throw new ArgumentNullException (nameof (assemblies)); _ = systemRuntimeVersion ?? throw new ArgumentNullException (nameof (systemRuntimeVersion)); @@ -47,6 +48,7 @@ public TrimmableTypeMapResult Execute ( } MarkFrameworkAssemblyPeers (allPeers, frameworkAssemblyNames); + RootCustomViewTypes (allPeers, customViewTypeNames); RootManifestReferencedTypes (allPeers, PrepareManifestForRooting (manifestTemplate, manifestConfig), manifestConfig?.ApplicationJavaClass); PropagateDeferredRegistrationToBaseClasses (allPeers); PropagateCannotRegisterToDescendants (allPeers); @@ -73,6 +75,20 @@ public TrimmableTypeMapResult Execute ( return new TrimmableTypeMapResult (generatedAssemblies, generatedJavaSources, allPeers, manifest, appRegTypes); } + internal static void RootCustomViewTypes (List allPeers, IReadOnlyCollection? customViewTypeNames) + { + if (customViewTypeNames is null || customViewTypeNames.Count == 0) { + return; + } + + var names = new HashSet (customViewTypeNames, StringComparer.Ordinal); + foreach (var peer in allPeers) { + if (names.Contains (peer.ManagedTypeName)) { + peer.IsUnconditional = true; + } + } + } + internal bool ValidateJavaNames (IReadOnlyList peers, string? applicationJavaClass = null) { bool valid = true; diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets index a2dfc834ca8..3ce987089dd 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets @@ -60,7 +60,7 @@ <_GenerateTrimmableTypeMapDependsOn Condition=" '$(_AndroidRuntime)' == 'NativeAOT' ">$(IlcDynamicBuildPropertyDependencies) - <_GenerateTrimmableTypeMapDependsOn>$(_GenerateTrimmableTypeMapDependsOn);_GetLibraryImports + <_GenerateTrimmableTypeMapDependsOn>$(_GenerateTrimmableTypeMapDependsOn);_GetLibraryImports;_ConvertResourcesCases _RecordTrimmableTypeMapFileWrites; $(IncrementalCleanDependsOn); @@ -120,7 +120,7 @@ Condition=" '$(AndroidTypeMapImplementation)' == 'trimmable' and '$(DesignTimeBuild)' != 'true' and '@(ReferencePath->Count())' != '0' and '$(_OuterIntermediateOutputPath)' == '' " AfterTargets="CoreCompile" DependsOnTargets="$(_GenerateTrimmableTypeMapDependsOn)" - Inputs="@(ReferencePath);@(PrivateSdkAssemblies);@(FrameworkAssemblies);@(ExtractedManifestDocuments);@(_AndroidTrimmableTypeMapExtraFrameworkAssembly);$(IntermediateOutputPath)$(TargetFileName);$(_AndroidManifestAbs);$(_AndroidBuildPropertiesCache)" + Inputs="@(ReferencePath);@(PrivateSdkAssemblies);@(FrameworkAssemblies);@(ExtractedManifestDocuments);@(_AndroidTrimmableTypeMapExtraFrameworkAssembly);$(IntermediateOutputPath)$(TargetFileName);$(_AndroidManifestAbs);$(_CustomViewMapFile);$(_AndroidBuildPropertiesCache)" Outputs="$(_TrimmableTypeMapOutputStamp)"> @@ -152,6 +152,7 @@ JavaSourceOutputDirectory="$(_TypeMapJavaOutputDirectory)" TargetFrameworkVersion="$(TargetFrameworkVersion)" ManifestTemplate="$(_AndroidManifestAbs)" + CustomViewMapFile="$(_CustomViewMapFile)" MergedAndroidManifestOutput="$(_TypeMapBaseOutputDir)AndroidManifest.xml" MergedManifestDocuments="@(_MergedManifestDocuments)" PackageName="$(_AndroidPackage)" diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs index 7cffd69bb6a..25a5e615d83 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs @@ -87,6 +87,8 @@ public void LogCustomJavaObjectWarning (string managedTypeName) => public string? ManifestTemplate { get; set; } + public string? CustomViewMapFile { get; set; } + public string? MergedAndroidManifestOutput { get; set; } /// @@ -211,6 +213,9 @@ public override bool RunTask () if (!ManifestTemplate.IsNullOrEmpty () && File.Exists (ManifestTemplate)) { manifestTemplate = XDocument.Load (ManifestTemplate); } + IReadOnlyCollection? customViewTypeNames = CustomViewMapFile.IsNullOrEmpty () + ? null + : MonoAndroidHelper.LoadCustomViewMapFile (CustomViewMapFile).Keys; result = generator.Execute ( assemblies, @@ -221,7 +226,8 @@ public override bool RunTask () manifestTemplate: manifestTemplate, packageNamingPolicy: PackageNamingPolicy, generateTypeMapAssemblies: GenerateTypeMapAssemblies, - errorOnCustomJavaObject: ErrorOnCustomJavaObject); + errorOnCustomJavaObject: ErrorOnCustomJavaObject, + customViewTypeNames: customViewTypeNames); if (Log.HasLoggedErrors) { return false; } 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 b5512e330fe..efd87fd3e55 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 @@ -5,6 +5,7 @@ using System.Security.Cryptography; using System.Text.Json; using System.Text.RegularExpressions; +using Mono.Cecil; using NUnit.Framework; using Xamarin.Android.AssemblyStore; using Xamarin.Android.Tasks; @@ -770,6 +771,169 @@ public void ReleaseCoreClrTrimmableTypeMap_SingleRuntimeIdentifier_PackagesLinke AssertPostTrimR8InputsExcludeDeadFrameworkImplementor (dexFile, javaSourceDirectory, acwMapPath, proguardPrimaryPath); } + [Test] + public void ReleaseCoreClrTrimmableTypeMap_TrimsUnusedBindingListenerImplementors () + { + if (IgnoreUnsupportedConfiguration (AndroidRuntime.CoreCLR, release: true)) { + return; + } + + var testRoot = Path.Combine ("temp", $"{TestName}_{Guid.NewGuid ():N}"); + var binding = new XamarinAndroidBindingProject { + IsRelease = true, + ProjectName = "ListenerBinding", + AndroidClassParser = "class-parse", + }; + binding.SetRuntime (AndroidRuntime.CoreCLR); + + var javaRoot = Path.Combine (Root, testRoot, "java"); + var javaSource = Path.Combine ("com", "example", "listener", "Widget.java"); + Directory.CreateDirectory (Path.Combine (javaRoot, Path.GetDirectoryName (javaSource) ?? "")); + binding.Jars.Add (new AndroidItem.EmbeddedJar (Path.Combine ("java", "listener.jar")) { + BinaryContent = new JarContentBuilder { + BaseDirectory = javaRoot, + JarFileName = "listener.jar", + JavaSourceFileName = javaSource, + JavaSourceText = """ + package com.example.listener; + + public class Widget { + public interface OnChangedListener { + void onChanged (); + } + + public void setOnChangedListener (OnChangedListener listener) { + } + } + """, + }.Build, + }); + + using var bindingBuilder = CreateDllBuilder (Path.Combine (testRoot, binding.ProjectName)); + Assert.IsTrue (bindingBuilder.Build (binding), "Listener binding build should have succeeded."); + + foreach (bool useListener in new [] { false, true }) { + var app = new XamarinAndroidApplicationProject { + IsRelease = true, + PackageName = useListener ? "com.xamarin.listenerused" : "com.xamarin.listenerunused", + ProjectName = useListener ? "ListenerUsed" : "ListenerUnused", + }; + app.SetRuntime (AndroidRuntime.CoreCLR); + app.SetProperty (KnownProperties.RuntimeIdentifier, "android-arm64"); + app.SetProperty ("AndroidPackageFormat", "apk"); + app.SetProperty (KnownProperties.AndroidLinkTool, "r8"); + app.SetProperty ("TrimMode", "full"); + app.SetProperty ("PublishReadyToRun", "false"); + app.SetProperty ("AndroidTypeMapImplementation", "trimmable"); + app.References.Add (new BuildItem.ProjectReference ($"..\\{binding.ProjectName}\\{binding.ProjectName}.csproj", binding.ProjectName, binding.ProjectGuid)); + if (useListener) { + app.MainActivity = app.DefaultMainActivity.Replace ( + "//${AFTER_ONCREATE}", + """ + var widget = new Com.Example.Listener.Widget (); + widget.Changed += (sender, args) => { }; + """); + } + + using var builder = CreateApkBuilder (Path.Combine (testRoot, app.ProjectName)); + Assert.IsTrue (builder.Build (app), $"{app.ProjectName} build should have succeeded."); + + var linkedDirectory = builder.Output.GetIntermediaryPath (Path.Combine ("android-arm64", "linked")); + var linkedBinding = Path.Combine (linkedDirectory, $"{binding.ProjectName}.dll"); + var javaDirectory = builder.Output.GetIntermediaryPath (Path.Combine ("android-arm64", "typemap", "linked-java")); + var implementorJava = Path.Combine (javaDirectory, "mono", "com", "example", "listener", "Widget_OnChangedListenerImplementor.java"); + var acwMapPath = builder.Output.GetIntermediaryPath (Path.Combine ("android-arm64", "acw-map.txt")); + var proguardPath = builder.Output.GetIntermediaryPath (Path.Combine ("android-arm64", "proguard", "proguard_project_primary.cfg")); + var dexPath = builder.Output.GetIntermediaryPath (Path.Combine ("android-arm64", "android", "bin", "classes.dex")); + + Assert.AreEqual ( + useListener, + AssemblyContainsTypeNamed (linkedBinding, "IOnChangedListenerImplementor"), + $"{app.ProjectName} linked managed output should {(useListener ? "retain" : "trim")} the listener implementor."); + Assert.AreEqual ( + useListener, + File.Exists (implementorJava), + $"{app.ProjectName} post-trim Java output should {(useListener ? "retain" : "trim")} the listener implementor."); + AssertFileContains ( + acwMapPath, + "IOnChangedListenerImplementor", + useListener, + $"{app.ProjectName} ACW map"); + AssertFileContains ( + proguardPath, + "mono.com.example.listener.Widget_OnChangedListenerImplementor", + useListener, + $"{app.ProjectName} ProGuard configuration"); + Assert.AreEqual ( + useListener, + DexUtils.ContainsClass ("Lmono/com/example/listener/Widget_OnChangedListenerImplementor;", dexPath, AndroidSdkPath), + $"{app.ProjectName} DEX should {(useListener ? "retain" : "trim")} the listener implementor."); + } + } + + [Test] + public void ReleaseCoreClrTrimmableTypeMap_UsesExternalJavaRoots () + { + if (IgnoreUnsupportedConfiguration (AndroidRuntime.CoreCLR, release: true)) { + return; + } + + var app = new XamarinAndroidApplicationProject { + IsRelease = true, + PackageName = "com.xamarin.externaljavaroots", + ProjectName = "ExternalJavaRoots", + }; + app.SetRuntime (AndroidRuntime.CoreCLR); + app.SetProperty (KnownProperties.RuntimeIdentifier, "android-arm64"); + app.SetProperty ("AndroidPackageFormat", "apk"); + app.SetProperty ("TrimMode", "full"); + app.SetProperty ("PublishReadyToRun", "false"); + app.SetProperty ("AndroidTypeMapImplementation", "trimmable"); + app.Sources.Add (new BuildItem.Source ("Views.cs") { + TextContent = () => """ + using Android.Content; + using Android.Util; + using Android.Views; + + namespace ExternalJavaRoots; + + public class LayoutOnlyView : View + { + public LayoutOnlyView (Context context, IAttributeSet attributes) : base (context, attributes) + { + } + } + + public class UnusedView : View + { + public UnusedView (Context context) : base (context) + { + } + } + """, + }); + app.AndroidResources.Add (new AndroidItem.AndroidResource ("Resources\\layout\\layout_only.xml") { + TextContent = () => """ + + + """, + }); + + using var builder = CreateApkBuilder (Path.Combine ("temp", $"{TestName}_{Guid.NewGuid ():N}")); + Assert.IsTrue (builder.Build (app), "External Java roots build should have succeeded."); + + var linkedApp = builder.Output.GetIntermediaryPath (Path.Combine ("android-arm64", "linked", $"{app.ProjectName}.dll")); + Assert.IsTrue (AssemblyContainsTypeNamed (linkedApp, "LayoutOnlyView"), "The XML-only custom view should survive linking."); + Assert.IsFalse (AssemblyContainsTypeNamed (linkedApp, "UnusedView"), "An unreferenced ACW should be trimmed."); + + var javaDirectory = builder.Output.GetIntermediaryPath (Path.Combine ("android-arm64", "typemap", "linked-java")); + Assert.IsNotEmpty (Directory.GetFiles (javaDirectory, "LayoutOnlyView.java", SearchOption.AllDirectories)); + Assert.IsEmpty (Directory.GetFiles (javaDirectory, "UnusedView.java", SearchOption.AllDirectories)); + } + [Test] public void TrimmableTypeMap_PreserveLists_ArePackagedInSdk () { @@ -1010,6 +1174,38 @@ static void AssertTrimmableTypeMapOutputs (string typemapDir) var javaFiles = Directory.GetFiles (javaDir, "*.java", SearchOption.AllDirectories); Assert.IsNotEmpty (javaFiles, "At least one trimmable JCW Java source file should be generated."); } + + static bool AssemblyContainsTypeNamed (string assemblyPath, string typeName) + { + if (!File.Exists (assemblyPath)) { + return false; + } + + using var assembly = AssemblyDefinition.ReadAssembly (assemblyPath); + return ContainsTypeNamed (assembly.MainModule.Types, typeName); + } + + static bool ContainsTypeNamed (IEnumerable types, string typeName) + { + foreach (var type in types) { + if (type.Name == typeName || ContainsTypeNamed (type.NestedTypes, typeName)) { + return true; + } + } + + return false; + } + + static void AssertFileContains (string path, string value, bool expected, string description) + { + FileAssert.Exists (path, $"{description} should exist."); + var contents = File.ReadAllText (path); + Assert.AreEqual ( + expected, + contents.Contains (value, StringComparison.Ordinal), + $"{description} should {(expected ? "contain" : "exclude")} '{value}'."); + } + DynamicCodeSupportProfile BuildDynamicCodeSupportProfile (string typemapImplementation, bool? dynamicCodeSupport) { var dynamicCodeSuffix = dynamicCodeSupport.HasValue ? $"_{dynamicCodeSupport.Value.ToString ().ToLowerInvariant ()}" : ""; diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs index 567df31d17f..c3128abbbec 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs @@ -700,6 +700,28 @@ public void RootManifestReferencedTypes_RootsManifestReferencedTypes ( Assert.Contains (logMessages, m => m.Contains ("Rooting manifest-referenced type")); } + [Fact] + public void RootCustomViewTypes_RootsOnlyReferencedManagedTypes () + { + var peers = new List { + new JavaPeerInfo { + JavaName = "crc64123456789abc/CustomView", CompatJniName = "my.app.CustomView", + ManagedTypeName = "MyApp.CustomView", ManagedTypeNamespace = "MyApp", ManagedTypeShortName = "CustomView", + AssemblyName = "MyApp", + }, + new JavaPeerInfo { + JavaName = "crc64123456789abc/UnusedView", CompatJniName = "my.app.UnusedView", + ManagedTypeName = "MyApp.UnusedView", ManagedTypeNamespace = "MyApp", ManagedTypeShortName = "UnusedView", + AssemblyName = "MyApp", + }, + }; + + TrimmableTypeMapGenerator.RootCustomViewTypes (peers, ["MyApp.CustomView"]); + + Assert.True (peers [0].IsUnconditional); + Assert.False (peers [1].IsUnconditional); + } + [Fact] public void RootManifestReferencedTypes_RootsApplicationAndInstrumentationTypes () { diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs index f66eb72f608..257978bb229 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs @@ -164,10 +164,8 @@ public void Build_AllMcwAliasGroup_BaseEntryIsConditional () } [Fact] - public void Build_MixedAcwMcwAliasGroup_BaseEntryIsUnconditional () + public void Build_MixedAcwMcwAliasGroup_BaseEntryIsConditional () { - // When at least one peer in an alias group is an ACW (unconditional), - // the base alias-holder entry should be unconditional (2-arg). var peers = new List { MakeMcwPeer ("test/Mixed", "Test.Mcw", "A") with { DoNotGenerateAcw = true }, MakeAcwPeer ("test/Mixed", "Test.Acw", "A"), @@ -175,8 +173,8 @@ public void Build_MixedAcwMcwAliasGroup_BaseEntryIsUnconditional () var model = BuildModel (peers); var baseEntry = model.Entries.Single (e => e.MapKey == "test/Mixed"); - Assert.True (baseEntry.IsUnconditional, "Mixed alias group with ACW should have unconditional base entry"); - Assert.Null (baseEntry.TargetTypeReference); + Assert.False (baseEntry.IsUnconditional); + Assert.NotNull (baseEntry.TargetTypeReference); } [Fact] @@ -215,16 +213,14 @@ public void Build_AllEssentialRuntimeTypes_AreUnconditional (string jniName) } [Fact] - public void Build_UserAcwType_IsUnconditional () + public void Build_UserAcwType_IsTrimmable () { - // User-defined ACW types (not MCW, not interface) are unconditional - // because Android can instantiate them from Java var peer = MakeAcwPeer ("my/app/Main", "MyApp.MainActivity", "App"); var model = BuildModel (new [] { peer }); var mainEntry = model.Entries.First (e => e.MapKey == "my/app/Main"); - Assert.True (mainEntry.IsUnconditional); - Assert.Null (mainEntry.TargetTypeReference); + Assert.False (mainEntry.IsUnconditional); + Assert.Equal ("MyApp.MainActivity, App", mainEntry.TargetTypeReference); } [Fact] @@ -279,7 +275,7 @@ public void Build_UnconditionalScannedType_IsUnconditional () } [Fact] - public void Build_FrameworkAcwType_IsConditional () + public void Build_AcwTypes_AreConditionalUnlessExplicitlyRooted () { var frameworkAcwPeer = MakeAcwPeer ("mono/android/view/View_ClickEventDispatcher", "Android.Views.View_ClickEventDispatcher", "Mono.Android") with { IsFrameworkAssembly = true, @@ -289,9 +285,9 @@ public void Build_FrameworkAcwType_IsConditional () Assert.False ( BuildModel ([frameworkAcwPeer]).Entries.Single ().IsUnconditional, "Framework ACWs should not unconditionally root their proxy types."); - Assert.True ( + Assert.False ( BuildModel ([appAcwPeer]).Entries.Single ().IsUnconditional, - "Application ACWs must remain unconditional because Java can instantiate them."); + "Application ACWs should follow managed reachability unless an external Java root marks them unconditional."); } } @@ -443,12 +439,12 @@ public class FixtureConditionalAttributes [Theory] [InlineData ("my/app/MainActivity")] [InlineData ("my/app/TouchHandler")] - public void Fixture_UserAcwType_IsUnconditional (string javaName) + public void Fixture_UserAcwType_UsesExplicitRoots (string javaName) { var peer = FindFixtureByJavaName (javaName); Assert.False (peer.DoNotGenerateAcw); var model = BuildModel (new [] { peer }); - Assert.True (model.Entries [0].IsUnconditional); + Assert.Equal (peer.IsUnconditional, model.Entries [0].IsUnconditional); } [Theory] @@ -754,9 +750,9 @@ public void Fixture_AcwType_HasProxy (string javaName, string expectedProxyName) public class FixtureImplementorsAndDispatchers { [Theory] - [InlineData ("mono/android/view/View_IOnClickListenerImplementor", "Implementor")] - [InlineData ("mono/android/view/View_ClickEventDispatcher", "EventDispatcher")] - public void Fixture_HelperType_IsUnconditional (string javaName, string kind) + [InlineData ("mono/android/view/View_IOnClickListenerImplementor")] + [InlineData ("mono/android/view/View_ClickEventDispatcher")] + public void Fixture_HelperType_IsConditional (string javaName) { var peer = FindFixtureByJavaName (javaName); Assert.False (peer.DoNotGenerateAcw); @@ -766,9 +762,7 @@ public void Fixture_HelperType_IsUnconditional (string javaName, string kind) var entry = model.Entries.FirstOrDefault (); Assert.NotNull (entry); - // Implementor/EventDispatcher types are treated as unconditional ACW types. - // Future optimization (see #10911) may make them trimmable. - Assert.True (entry.IsUnconditional, $"{kind} should be unconditional"); + Assert.False (entry.IsUnconditional); } } diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.cs index f8c9192146b..8d4a70c888e 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.cs @@ -57,6 +57,7 @@ public void Scan_MarksFrameworkAssemblyPeers () Assert.NotEmpty (peers); Assert.All (peers, p => Assert.True (p.IsFrameworkAssembly, $"{p.ManagedTypeName} should be marked as a framework peer.")); + Assert.All (peers, p => Assert.False (p.IsUnconditional, $"{p.ManagedTypeName} should not be rooted by framework assembly attributes.")); } [Fact] @@ -130,6 +131,7 @@ public void Scan_JniTypeSignature_IsDiscovered () var peer = FindFixtureByJavaName ("net/dot/jni/test/JavaDisposedObject"); Assert.Equal ("Java.Interop.TestTypes.JavaDisposedObject", peer.ManagedTypeName); Assert.False (peer.DoNotGenerateAcw, "GenerateJavaPeer=true should map to DoNotGenerateAcw=false"); + Assert.True (peer.IsUnconditional, "Non-framework JniTypeSignature peers should match the legacy IJniNameProviderAttribute root."); } [Fact] From 021ed07c5a3145911d3f6e1bcf03559ffe06a2e8 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 24 Aug 2026 22:56:01 +0200 Subject: [PATCH 2/3] Reuse the cached custom view map Load custom view roots through the build-engine cache shared with the resource conversion tasks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a6de09d8-fc09-4560-a976-ffcccd21ba70 --- .../Tasks/GenerateTrimmableTypeMap.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs index 25a5e615d83..5cfa762ba1c 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs @@ -215,7 +215,7 @@ public override bool RunTask () } IReadOnlyCollection? customViewTypeNames = CustomViewMapFile.IsNullOrEmpty () ? null - : MonoAndroidHelper.LoadCustomViewMapFile (CustomViewMapFile).Keys; + : MonoAndroidHelper.LoadCustomViewMapFile (BuildEngine4, CustomViewMapFile).Keys; result = generator.Execute ( assemblies, From c0d288e1129f445dd2166b604aafd6192324719f Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 24 Aug 2026 23:33:16 +0200 Subject: [PATCH 3/3] Match linked test types by full name Use unambiguous Cecil full names when asserting whether trimming retained generated implementors and custom views. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a6de09d8-fc09-4560-a976-ffcccd21ba70 --- .../TrimmableTypeMapBuildTests.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 efd87fd3e55..3fcbee74446 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 @@ -848,7 +848,7 @@ public void setOnChangedListener (OnChangedListener listener) { Assert.AreEqual ( useListener, - AssemblyContainsTypeNamed (linkedBinding, "IOnChangedListenerImplementor"), + AssemblyContainsType (linkedBinding, "Com.Example.Listener.Widget/IOnChangedListenerImplementor"), $"{app.ProjectName} linked managed output should {(useListener ? "retain" : "trim")} the listener implementor."); Assert.AreEqual ( useListener, @@ -926,8 +926,8 @@ public UnusedView (Context context) : base (context) Assert.IsTrue (builder.Build (app), "External Java roots build should have succeeded."); var linkedApp = builder.Output.GetIntermediaryPath (Path.Combine ("android-arm64", "linked", $"{app.ProjectName}.dll")); - Assert.IsTrue (AssemblyContainsTypeNamed (linkedApp, "LayoutOnlyView"), "The XML-only custom view should survive linking."); - Assert.IsFalse (AssemblyContainsTypeNamed (linkedApp, "UnusedView"), "An unreferenced ACW should be trimmed."); + Assert.IsTrue (AssemblyContainsType (linkedApp, "ExternalJavaRoots.LayoutOnlyView"), "The XML-only custom view should survive linking."); + Assert.IsFalse (AssemblyContainsType (linkedApp, "ExternalJavaRoots.UnusedView"), "An unreferenced ACW should be trimmed."); var javaDirectory = builder.Output.GetIntermediaryPath (Path.Combine ("android-arm64", "typemap", "linked-java")); Assert.IsNotEmpty (Directory.GetFiles (javaDirectory, "LayoutOnlyView.java", SearchOption.AllDirectories)); @@ -1175,20 +1175,20 @@ static void AssertTrimmableTypeMapOutputs (string typemapDir) Assert.IsNotEmpty (javaFiles, "At least one trimmable JCW Java source file should be generated."); } - static bool AssemblyContainsTypeNamed (string assemblyPath, string typeName) + static bool AssemblyContainsType (string assemblyPath, string typeFullName) { if (!File.Exists (assemblyPath)) { return false; } using var assembly = AssemblyDefinition.ReadAssembly (assemblyPath); - return ContainsTypeNamed (assembly.MainModule.Types, typeName); + return ContainsType (assembly.MainModule.Types, typeFullName); } - static bool ContainsTypeNamed (IEnumerable types, string typeName) + static bool ContainsType (IEnumerable types, string typeFullName) { foreach (var type in types) { - if (type.Name == typeName || ContainsTypeNamed (type.NestedTypes, typeName)) { + if (type.FullName == typeFullName || ContainsType (type.NestedTypes, typeFullName)) { return true; } }