From b7e932e16692f826e53451b54fb31b387510b361 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 16 Jul 2026 10:45:23 +0200 Subject: [PATCH 1/2] [NativeAOT] Emit XA4212 for custom IJavaObject types on the trimmable path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-cause fix (replaces the earlier test skip). The XA4212 "custom IJavaObject not supported" diagnostic was produced only by the Cecil-based XAJavaTypeScanner, which runs on the managed path (AssemblyModifierPipeline in _RunAfterILLinkAdditionalSteps) and the llvm-ir path. On the trimmable typemap path — now the NativeAOT default — that post-ILLink pipeline is intentionally skipped as dead work, so a managed class implementing Android.Runtime.IJavaObject without deriving from Java.Lang.Object/Throwable was silently ignored: no diagnostic, and the type is absent from the typemap, failing at runtime. Detect this case directly in JavaPeerScanner, which already scans every type: a class that is not a Java peer, is not a System.Exception subclass, and implements Android.Runtime.IJavaObject (directly, transitively via an interface, or through a base class) now emits XA4212. Thread AndroidErrorOnCustomJavaObject through the GenerateTrimmableTypeMap task and generator so it errors by default and warns when disabled, matching the legacy behavior and message format. Un-skip the XA4212 test. Verified locally: XA4212(NativeAOT) and XA4212(CoreCLR) pass, 614 trimmable-typemap generator unit tests pass, and BuildBasicApplicationAppCompat (NativeAOT + CoreCLR) builds clean with no false-positive XA4212. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 94f450c7-781d-4019-a56c-fae1c456b9a7 --- .../ITrimmableTypeMapLogger.cs | 2 + .../Scanner/JavaPeerScanner.cs | 109 +++++++++++++++++- .../TrimmableTypeMapGenerator.cs | 10 +- ...soft.Android.Sdk.TypeMap.Trimmable.targets | 1 + .../Properties/Resources.Designer.cs | 9 ++ .../Properties/Resources.resx | 5 + .../Tasks/GenerateTrimmableTypeMap.cs | 14 ++- .../TrimmableTypeMapGeneratorTests.cs | 4 + 8 files changed, 148 insertions(+), 6 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/ITrimmableTypeMapLogger.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/ITrimmableTypeMapLogger.cs index 636b22e25b0..1d947153cef 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/ITrimmableTypeMapLogger.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/ITrimmableTypeMapLogger.cs @@ -21,4 +21,6 @@ void LogUnresolvableJavaPeerSkippedWarning ( string unresolvedAssemblyName, string unresolvedAssemblyPath); void LogJniAddNativeMethodRegistrationAttributeError (string managedTypeName); + void LogCustomJavaObjectError (string managedTypeName); + void LogCustomJavaObjectWarning (string managedTypeName); } diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs index f239f509f54..77567365d88 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs @@ -32,12 +32,14 @@ enum HashedPackageNamingPolicy { readonly ITrimmableTypeMapLogger? logger; readonly HashedPackageNamingPolicy packageNamingPolicy; readonly HashSet frameworkAssemblyNames; + readonly bool errorOnCustomJavaObject; - public JavaPeerScanner (string? packageNamingPolicy = null, ITrimmableTypeMapLogger? logger = null, HashSet? frameworkAssemblyNames = null) + public JavaPeerScanner (string? packageNamingPolicy = null, ITrimmableTypeMapLogger? logger = null, HashSet? frameworkAssemblyNames = null, bool errorOnCustomJavaObject = false) { this.packageNamingPolicy = ParsePackageNamingPolicy (packageNamingPolicy); this.logger = logger; this.frameworkAssemblyNames = frameworkAssemblyNames ?? new HashSet (StringComparer.OrdinalIgnoreCase); + this.errorOnCustomJavaObject = errorOnCustomJavaObject; } /// @@ -338,6 +340,17 @@ void ScanAssembly (AssemblyIndex index, Dictionary<(string ManagedName, string A if (ExtendsJavaPeer (typeDef, index)) { (jniName, compatJniName) = ComputeAutoJniNames (typeDef, index); } else { + // A managed class that implements Android.Runtime.IJavaObject but does not + // derive from a Java peer (Java.Lang.Object / Java.Lang.Throwable) cannot be + // marshaled to Java. Mirror the legacy XAJavaTypeScanner XA4212 diagnostic, + // which the managed/llvm-ir typemap paths raise via GenerateJavaStubs. + if (IsCustomJavaObject (typeDef, index)) { + if (errorOnCustomJavaObject) { + logger?.LogCustomJavaObjectError (fullName); + } else { + logger?.LogCustomJavaObjectWarning (fullName); + } + } continue; } } @@ -2151,6 +2164,100 @@ public void Dispose () readonly Dictionary extendsJavaPeerCache = new (StringComparer.Ordinal); + const string IJavaObjectFullName = "Android.Runtime.IJavaObject"; + + readonly Dictionary implementsIJavaObjectCache = new (StringComparer.Ordinal); + + /// + /// Determines whether a type is a "custom" Java object: a managed class that implements + /// Android.Runtime.IJavaObject but does not derive from a Java peer (Java.Lang.Object / + /// Java.Lang.Throwable). Such types cannot be marshaled and produce XA4212. Interfaces and + /// System.Exception subclasses are excluded, matching the legacy XAJavaTypeScanner. + /// + bool IsCustomJavaObject (TypeDefinition typeDef, AssemblyIndex index) + { + if ((typeDef.Attributes & TypeAttributes.Interface) != 0) { + return false; + } + if (IsSubclassOfSystemException (typeDef, index)) { + return false; + } + return ImplementsIJavaObject (typeDef, index); + } + + /// + /// Check whether a type implements Android.Runtime.IJavaObject, directly or through an + /// interface that extends it, or via a base class. Results are cached; false-before-recurse + /// prevents cycles. + /// + bool ImplementsIJavaObject (TypeDefinition typeDef, AssemblyIndex index) + { + var fullName = MetadataTypeNameResolver.GetFullName (typeDef, index.Reader); + var key = $"{index.AssemblyName}:{fullName}"; + + if (implementsIJavaObjectCache.TryGetValue (key, out var cached)) { + return cached; + } + + // Mark as false to prevent cycles, then compute + implementsIJavaObjectCache [key] = false; + + foreach (var implHandle in typeDef.GetInterfaceImplementations ()) { + var impl = index.Reader.GetInterfaceImplementation (implHandle); + var resolved = ResolveEntityHandle (impl.Interface, index); + if (resolved is null) { + continue; + } + + if (resolved.ManagedTypeName == IJavaObjectFullName) { + implementsIJavaObjectCache [key] = true; + return true; + } + + // Recurse into the interface's own base interfaces + if (TryResolveType (resolved.ManagedTypeName, resolved.AssemblyName, out var ifaceHandle, out var ifaceIndex)) { + var ifaceDef = ifaceIndex.Reader.GetTypeDefinition (ifaceHandle); + if (ImplementsIJavaObject (ifaceDef, ifaceIndex)) { + implementsIJavaObjectCache [key] = true; + return true; + } + } + } + + // Walk the base class chain + var baseInfo = GetBaseTypeInfo (typeDef, index); + if (baseInfo is not null && + TryResolveType (baseInfo.ManagedTypeName, baseInfo.AssemblyName, out var baseHandle, out var baseIndex)) { + var baseDef = baseIndex.Reader.GetTypeDefinition (baseHandle); + if (ImplementsIJavaObject (baseDef, baseIndex)) { + implementsIJavaObjectCache [key] = true; + return true; + } + } + + return false; + } + + /// + /// Walk the base type chain to determine whether the type derives from System.Exception. + /// + bool IsSubclassOfSystemException (TypeDefinition typeDef, AssemblyIndex index) + { + var baseInfo = GetBaseTypeInfo (typeDef, index); + int guard = 0; + while (baseInfo is not null && guard++ < 256) { + if (baseInfo.ManagedTypeName == "System.Exception") { + return true; + } + if (!TryResolveType (baseInfo.ManagedTypeName, baseInfo.AssemblyName, out var baseHandle, out var baseIndex)) { + return false; + } + var baseDef = baseIndex.Reader.GetTypeDefinition (baseHandle); + baseInfo = GetBaseTypeInfo (baseDef, baseIndex); + } + return false; + } + /// /// Check if a type extends a known Java peer (has [Register] or component attribute) /// by walking the base type chain. Results are cached; false-before-recurse prevents cycles. diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs index fee0811ebc4..e0f503ad68d 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, int maxArrayRank = 0, - bool generateTypeMapAssemblies = true) + bool generateTypeMapAssemblies = true, + bool errorOnCustomJavaObject = false) { _ = assemblies ?? throw new ArgumentNullException (nameof (assemblies)); _ = systemRuntimeVersion ?? throw new ArgumentNullException (nameof (systemRuntimeVersion)); @@ -44,7 +45,7 @@ public TrimmableTypeMapResult Execute ( throw new ArgumentOutOfRangeException (nameof (maxArrayRank), maxArrayRank, "Must be >= 0."); } - var (allPeers, assemblyManifestInfo) = ScanAssemblies (assemblies, packageNamingPolicy, frameworkAssemblyNames); + var (allPeers, assemblyManifestInfo) = ScanAssemblies (assemblies, packageNamingPolicy, frameworkAssemblyNames, errorOnCustomJavaObject); if (allPeers.Count == 0) { logger.LogNoJavaPeerTypesFound (); return new TrimmableTypeMapResult ([], [], allPeers); @@ -166,9 +167,10 @@ GeneratedManifest GenerateManifest (List allPeers, AssemblyManifes (List peers, AssemblyManifestInfo manifestInfo) ScanAssemblies ( IReadOnlyList assemblies, string? packageNamingPolicy, - HashSet frameworkAssemblyNames) + HashSet frameworkAssemblyNames, + bool errorOnCustomJavaObject = false) { - using var scanner = new JavaPeerScanner (packageNamingPolicy, logger, frameworkAssemblyNames); + using var scanner = new JavaPeerScanner (packageNamingPolicy, logger, frameworkAssemblyNames, errorOnCustomJavaObject); var peers = scanner.Scan (assemblies); var manifestInfo = scanner.ScanAssemblyManifestInfo (); logger.LogJavaPeerScanInfo (assemblies.Count, peers.Count); 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 b66b2e0208d..ae9fae5e568 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 @@ -154,6 +154,7 @@ ManifestPlaceholders="$(AndroidManifestPlaceholders)" CheckedBuild="$(_AndroidCheckedBuild)" ApplicationJavaClass="$(AndroidApplicationJavaClass)" + ErrorOnCustomJavaObject="$(AndroidErrorOnCustomJavaObject)" GeneratedAssembliesListFile="$(_TypeMapAssembliesListFile)" AcwMapOutputFile="$(IntermediateOutputPath)acw-map.txt" ApplicationRegistrationOutputFile="$(IntermediateOutputPath)android/src/net/dot/android/ApplicationRegistration.java"> diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs index 0d77d814434..aa05531d40b 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs @@ -1206,6 +1206,15 @@ public static string XA4213 { } } + /// + /// Looks up a localized string similar to Type `{0}` implements `Android.Runtime.IJavaObject` but does not inherit `Java.Lang.Object` or `Java.Lang.Throwable`. This is not supported.. + /// + public static string XA4212 { + get { + return ResourceManager.GetString("XA4212", resourceCulture); + } + } + /// /// Looks up a localized string similar to The managed type `{0}` exists in multiple assemblies: {1}. Please refactor the managed type names in these assemblies so that they are not identical.. /// diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx index 3acea7e9bd5..d0014227f83 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx @@ -603,6 +603,11 @@ Either change the value in the AndroidManifest.xml to match the $(SupportedOSPla The type '{0}' must provide a public default constructor + + Type `{0}` implements `Android.Runtime.IJavaObject` but does not inherit `Java.Lang.Object` or `Java.Lang.Throwable`. This is not supported. + The following are literal names and should not be translated: Android.Runtime.IJavaObject, Java.Lang.Object, Java.Lang.Throwable +{0} - The managed type name + The managed type `{0}` exists in multiple assemblies: {1}. Please refactor the managed type names in these assemblies so that they are not identical. {0} - The managed type name diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs index b1dcd144167..140ccbd6c03 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs @@ -57,6 +57,10 @@ public void LogUnresolvableJavaPeerSkippedWarning ( log.LogCodedWarning ("XA4257", Properties.Resources.XA4257, managedTypeName, assemblyName, unresolvedTypeName, unresolvedAssemblyName, unresolvedAssemblyPath); public void LogJniAddNativeMethodRegistrationAttributeError (string managedTypeName) => log.LogCodedError ("XA4251", Properties.Resources.XA4251, managedTypeName); + public void LogCustomJavaObjectError (string managedTypeName) => + log.LogError ("{0}", $"XA4212: {string.Format (Properties.Resources.XA4212, managedTypeName)}"); + public void LogCustomJavaObjectWarning (string managedTypeName) => + log.LogWarning ("{0}", $"XA4212: {string.Format (Properties.Resources.XA4212, managedTypeName)}"); } public override string TaskPrefix => "GTT"; @@ -115,6 +119,13 @@ public void LogJniAddNativeMethodRegistrationAttributeError (string managedTypeN public bool GenerateTypeMapAssemblies { get; set; } = true; public bool CleanJavaSourceOutputDirectory { get; set; } + /// + /// When true (the default, from $(AndroidErrorOnCustomJavaObject)), a managed class + /// that implements Android.Runtime.IJavaObject without deriving from a Java peer is + /// reported as the XA4212 error; otherwise it is reported as a warning. + /// + public bool ErrorOnCustomJavaObject { get; set; } = true; + [Output] public ITaskItem [] GeneratedAssemblies { get; set; } = []; [Output] @@ -215,7 +226,8 @@ public override bool RunTask () manifestTemplate: manifestTemplate, packageNamingPolicy: PackageNamingPolicy, maxArrayRank: MaxArrayRank, - generateTypeMapAssemblies: GenerateTypeMapAssemblies); + generateTypeMapAssemblies: GenerateTypeMapAssemblies, + errorOnCustomJavaObject: ErrorOnCustomJavaObject); if (GenerateTypeMapAssemblies) { GeneratedAssemblies = WriteAssembliesToDisk (result.GeneratedAssemblies, assemblyInputs.Select (i => i.Path).ToList ()); diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs index fb9829ca648..11362d09ce2 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs @@ -51,6 +51,10 @@ public void LogUnresolvableJavaPeerSkippedWarning ( $"'{unresolvedTypeName}' from '{unresolvedAssemblyName}' at '{unresolvedAssemblyPath}' could not be resolved."); public void LogJniAddNativeMethodRegistrationAttributeError (string managedTypeName) => logMessages.Add ($"XA4251: Type '{managedTypeName}' uses [JniAddNativeMethodRegistrationAttribute], which is not supported by the trimmable type map."); + public void LogCustomJavaObjectError (string managedTypeName) => + warnings?.Add ($"XA4212: Type `{managedTypeName}` implements `Android.Runtime.IJavaObject` but does not inherit `Java.Lang.Object` or `Java.Lang.Throwable`. This is not supported."); + public void LogCustomJavaObjectWarning (string managedTypeName) => + warnings?.Add ($"XA4212: Type `{managedTypeName}` implements `Android.Runtime.IJavaObject` but does not inherit `Java.Lang.Object` or `Java.Lang.Throwable`. This is not supported."); } [Fact] From 73c5aa5eec5305265e2e53070676c3fd460b9844 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 17 Jul 2026 10:15:26 +0200 Subject: [PATCH 2/2] [Tests] Re-enable XA4212 for NativeAOT Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3cd01877-7117-47b4-9377-a4ade8e61723 --- .../Tests/Xamarin.Android.Build.Tests/BuildTest.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildTest.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildTest.cs index 468c8322a5f..6e8bb5c7385 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildTest.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildTest.cs @@ -1059,12 +1059,6 @@ public void XA4212 ([Values (AndroidRuntime.CoreCLR, AndroidRuntime.NativeAOT)] if (IgnoreUnsupportedConfiguration (runtime, release: isRelease)) { return; } - // TODO: the trimmable typemap generator (the NativeAOT default) does not yet emit the - // XA4212 custom-IJavaObject diagnostic that the managed/llvm-ir typemap paths raise via - // XAJavaTypeScanner. Re-enable once that detection is added to TrimmableTypeMapGenerator. - if (IgnoreOnNativeAot (runtime, "the trimmable typemap does not yet emit the XA4212 custom-IJavaObject diagnostic (tracked as a follow-up).")) { - return; - } var proj = new XamarinAndroidApplicationProject () { IsRelease = isRelease,