diff --git a/Xamarin.Android.sln b/Xamarin.Android.sln
index d1edbe95c97..5383af42e4a 100644
--- a/Xamarin.Android.sln
+++ b/Xamarin.Android.sln
@@ -59,6 +59,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Xamarin.ProjectTools", "src
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Xamarin.Android.Build.Tests", "src\Xamarin.Android.Build.Tasks\Tests\Xamarin.Android.Build.Tests\Xamarin.Android.Build.Tests.csproj", "{53E4ABF0-1085-45F9-B964-DCAE4B819998}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Android.Sdk.TrimmableTypeMap", "src\Microsoft.Android.Sdk.TrimmableTypeMap\Microsoft.Android.Sdk.TrimmableTypeMap.csproj", "{507759AE-93DF-411B-8645-31F680319F5C}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests", "tests\Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests\Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests.csproj", "{A14CB0A1-7A05-4F27-88B2-383798CE1DEE}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UserTypesFixture", "tests\Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests\UserTypesFixture\UserTypesFixture.csproj", "{2498F8A0-AA04-40EF-8691-59BBD2396B4D}"
+EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "class-parse", "external\Java.Interop\tools\class-parse\class-parse.csproj", "{38C762AB-8FD1-44DE-9855-26AAE7129DC3}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "logcat-parse", "external\Java.Interop\tools\logcat-parse\logcat-parse.csproj", "{7387E151-48E3-4885-B2CA-A74434A34045}"
@@ -231,6 +237,18 @@ Global
{53E4ABF0-1085-45F9-B964-DCAE4B819998}.Debug|AnyCPU.Build.0 = Debug|Any CPU
{53E4ABF0-1085-45F9-B964-DCAE4B819998}.Release|AnyCPU.ActiveCfg = Release|Any CPU
{53E4ABF0-1085-45F9-B964-DCAE4B819998}.Release|AnyCPU.Build.0 = Release|Any CPU
+ {507759AE-93DF-411B-8645-31F680319F5C}.Debug|AnyCPU.ActiveCfg = Debug|Any CPU
+ {507759AE-93DF-411B-8645-31F680319F5C}.Debug|AnyCPU.Build.0 = Debug|Any CPU
+ {507759AE-93DF-411B-8645-31F680319F5C}.Release|AnyCPU.ActiveCfg = Release|Any CPU
+ {507759AE-93DF-411B-8645-31F680319F5C}.Release|AnyCPU.Build.0 = Release|Any CPU
+ {A14CB0A1-7A05-4F27-88B2-383798CE1DEE}.Debug|AnyCPU.ActiveCfg = Debug|Any CPU
+ {A14CB0A1-7A05-4F27-88B2-383798CE1DEE}.Debug|AnyCPU.Build.0 = Debug|Any CPU
+ {A14CB0A1-7A05-4F27-88B2-383798CE1DEE}.Release|AnyCPU.ActiveCfg = Release|Any CPU
+ {A14CB0A1-7A05-4F27-88B2-383798CE1DEE}.Release|AnyCPU.Build.0 = Release|Any CPU
+ {2498F8A0-AA04-40EF-8691-59BBD2396B4D}.Debug|AnyCPU.ActiveCfg = Debug|Any CPU
+ {2498F8A0-AA04-40EF-8691-59BBD2396B4D}.Debug|AnyCPU.Build.0 = Debug|Any CPU
+ {2498F8A0-AA04-40EF-8691-59BBD2396B4D}.Release|AnyCPU.ActiveCfg = Release|Any CPU
+ {2498F8A0-AA04-40EF-8691-59BBD2396B4D}.Release|AnyCPU.Build.0 = Release|Any CPU
{38C762AB-8FD1-44DE-9855-26AAE7129DC3}.Debug|AnyCPU.ActiveCfg = Debug|Any CPU
{38C762AB-8FD1-44DE-9855-26AAE7129DC3}.Debug|AnyCPU.Build.0 = Debug|Any CPU
{38C762AB-8FD1-44DE-9855-26AAE7129DC3}.Release|AnyCPU.ActiveCfg = Release|Any CPU
@@ -398,6 +416,8 @@ Global
{645E1718-C8C4-4C23-8A49-5A37E4ECF7ED} = {04E3E11E-B47D-4599-8AFC-50515A95E715}
{2DD1EE75-6D8D-4653-A800-0A24367F7F38} = {CAB438D8-B0F5-4AF0-BEBD-9E2ADBD7B483}
{53E4ABF0-1085-45F9-B964-DCAE4B819998} = {CAB438D8-B0F5-4AF0-BEBD-9E2ADBD7B483}
+ {A14CB0A1-7A05-4F27-88B2-383798CE1DEE} = {CAB438D8-B0F5-4AF0-BEBD-9E2ADBD7B483}
+ {2498F8A0-AA04-40EF-8691-59BBD2396B4D} = {CAB438D8-B0F5-4AF0-BEBD-9E2ADBD7B483}
{38C762AB-8FD1-44DE-9855-26AAE7129DC3} = {864062D3-A415-4A6F-9324-5820237BA058}
{7387E151-48E3-4885-B2CA-A74434A34045} = {864062D3-A415-4A6F-9324-5820237BA058}
{8A6CB07C-E493-4A4F-AB94-038645A27118} = {E351F97D-EA4F-4E7F-AAA0-8EBB1F2A4A62}
diff --git a/build-tools/automation/yaml-templates/build-windows-steps.yaml b/build-tools/automation/yaml-templates/build-windows-steps.yaml
index 6d3d6738ad2..f7cd09ce3b3 100644
--- a/build-tools/automation/yaml-templates/build-windows-steps.yaml
+++ b/build-tools/automation/yaml-templates/build-windows-steps.yaml
@@ -77,6 +77,36 @@ steps:
testRunTitle: Microsoft.Android.Sdk.Analysis.Tests
continueOnError: true
+- template: /build-tools/automation/yaml-templates/run-dotnet-preview.yaml@self
+ parameters:
+ command: test
+ project: tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests.csproj
+ arguments: -c $(XA.Build.Configuration) --logger trx --results-directory $(Agent.TempDirectory)/trimmable-typemap-tests
+ displayName: Test Microsoft.Android.Sdk.TrimmableTypeMap.Tests $(XA.Build.Configuration)
+
+- task: PublishTestResults@2
+ displayName: publish Microsoft.Android.Sdk.TrimmableTypeMap.Tests results
+ condition: always()
+ inputs:
+ testResultsFormat: VSTest
+ testResultsFiles: "$(Agent.TempDirectory)/trimmable-typemap-tests/*.trx"
+ testRunTitle: Microsoft.Android.Sdk.TrimmableTypeMap.Tests
+
+- template: /build-tools/automation/yaml-templates/run-dotnet-preview.yaml@self
+ parameters:
+ command: test
+ project: bin/Test$(XA.Build.Configuration)/$(DotNetTargetFramework)/Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests.dll
+ arguments: --logger trx --results-directory $(Agent.TempDirectory)/trimmable-typemap-integration-tests
+ displayName: Test Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests $(XA.Build.Configuration)
+
+- task: PublishTestResults@2
+ displayName: publish Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests results
+ condition: always()
+ inputs:
+ testResultsFormat: VSTest
+ testResultsFiles: "$(Agent.TempDirectory)/trimmable-typemap-integration-tests/*.trx"
+ testRunTitle: Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests
+
- task: BatchScript@1
displayName: Test dotnet-local.cmd - create template
inputs:
diff --git a/src/Xamarin.Android.Build.Tasks/Properties/AssemblyInfo.cs b/src/Xamarin.Android.Build.Tasks/Properties/AssemblyInfo.cs
index 0bd860a35e2..e66435ccc40 100644
--- a/src/Xamarin.Android.Build.Tasks/Properties/AssemblyInfo.cs
+++ b/src/Xamarin.Android.Build.Tasks/Properties/AssemblyInfo.cs
@@ -20,3 +20,4 @@
[assembly: InternalsVisibleTo ("Xamarin.Android.Build.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000011000000438ac2a5acfbf16cbd2b2b47a62762f273df9cb2795ceccdf77d10bf508e69e7a362ea7a45455bbf3ac955e1f2e2814f144e5d817efc4c6502cc012df310783348304e3ae38573c6d658c234025821fda87a0be8a0d504df564e2c93b2b878925f42503e9d54dfef9f9586d9e6f38a305769587b1de01f6c0410328b2c9733db")]
[assembly: InternalsVisibleTo ("MSBuildDeviceIntegration, PublicKey=0024000004800000940000000602000000240000525341310004000011000000438ac2a5acfbf16cbd2b2b47a62762f273df9cb2795ceccdf77d10bf508e69e7a362ea7a45455bbf3ac955e1f2e2814f144e5d817efc4c6502cc012df310783348304e3ae38573c6d658c234025821fda87a0be8a0d504df564e2c93b2b878925f42503e9d54dfef9f9586d9e6f38a305769587b1de01f6c0410328b2c9733db")]
+[assembly: InternalsVisibleTo ("Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests, PublicKey=0024000004800000940000000602000000240000525341310004000011000000438ac2a5acfbf16cbd2b2b47a62762f273df9cb2795ceccdf77d10bf508e69e7a362ea7a45455bbf3ac955e1f2e2814f144e5d817efc4c6502cc012df310783348304e3ae38573c6d658c234025821fda87a0be8a0d504df564e2c93b2b878925f42503e9d54dfef9f9586d9e6f38a305769587b1de01f6c0410328b2c9733db")]
diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests/Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests.csproj b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests/Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests.csproj
new file mode 100644
index 00000000000..35c76b21bbc
--- /dev/null
+++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests/Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests.csproj
@@ -0,0 +1,56 @@
+
+
+
+ $(DotNetTargetFramework)
+ latest
+ enable
+ false
+ Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests
+ true
+ ..\..\product.snk
+ ..\..\bin\Test$(Configuration)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_MonoAndroidRefCandidate Include="$(MSBuildThisFileDirectory)..\..\bin\$(Configuration)\lib\packs\Microsoft.Android.Ref.*\*\ref\net*\Mono.Android.dll" />
+
+
+ <_MonoAndroidRefAssembly>@(_MonoAndroidRefCandidate, ';')
+ <_MonoAndroidRefAssembly>$(_MonoAndroidRefAssembly.Split(';')[0])
+
+
+
+ $(_MonoAndroidRefAssembly)
+
+
+
+
+
diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests/MockBuildEngine.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests/MockBuildEngine.cs
new file mode 100644
index 00000000000..d6c8c19d1eb
--- /dev/null
+++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests/MockBuildEngine.cs
@@ -0,0 +1,22 @@
+using System;
+using System.Collections;
+using Microsoft.Build.Framework;
+
+namespace Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests;
+
+///
+/// Minimal IBuildEngine implementation for use with TaskLoggingHelper in tests.
+///
+sealed class MockBuildEngine : IBuildEngine
+{
+ public bool ContinueOnError => false;
+ public int LineNumberOfTaskNode => 0;
+ public int ColumnNumberOfTaskNode => 0;
+ public string ProjectFileOfTaskNode => "";
+
+ public bool BuildProjectFile (string projectFileName, string [] targetNames, IDictionary globalProperties, IDictionary targetOutputs) => true;
+ public void LogCustomEvent (CustomBuildEventArgs e) { }
+ public void LogErrorEvent (BuildErrorEventArgs e) { }
+ public void LogMessageEvent (BuildMessageEventArgs e) { }
+ public void LogWarningEvent (BuildWarningEventArgs e) { }
+}
diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests/ScannerComparisonTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests/ScannerComparisonTests.cs
new file mode 100644
index 00000000000..2551cc6d308
--- /dev/null
+++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests/ScannerComparisonTests.cs
@@ -0,0 +1,1348 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Java.Interop.Tools.Cecil;
+using Java.Interop.Tools.TypeNameMappings;
+using Microsoft.Build.Utilities;
+using Mono.Cecil;
+using Xamarin.Android.Tasks;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests;
+
+///
+/// Side-by-side comparison tests: runs both the legacy Cecil-based scanner
+/// and the new SRM-based scanner on the same assembly and compares their outputs.
+///
+public class ScannerComparisonTests
+{
+ readonly ITestOutputHelper output;
+
+ public ScannerComparisonTests (ITestOutputHelper output)
+ {
+ this.output = output;
+ }
+
+ ///
+ /// Represents a single type map entry: JNI name → managed type, with metadata.
+ ///
+ record TypeMapEntry (string JavaName, string ManagedName, bool SkipInJavaToManaged);
+
+ ///
+ /// Represents a method registration on a type: JNI method name + signature.
+ ///
+ record MethodEntry (string JniName, string JniSignature, string? Connector);
+
+ ///
+ /// Represents one managed type's methods for a given JNI name.
+ /// Multiple managed types can share the same JNI name (aliases).
+ ///
+ record TypeMethodGroup (string ManagedName, List Methods);
+
+ ///
+ /// Opens the assembly with Cecil and returns the scanner results plus methods per type.
+ /// Multiple managed types can map to the same JNI name (aliases).
+ ///
+ static (List entries, Dictionary> methodsByJavaName) RunLegacyScanner (string assemblyPath)
+ {
+ var cache = new TypeDefinitionCache ();
+ var resolver = new DefaultAssemblyResolver ();
+ resolver.AddSearchDirectory (Path.GetDirectoryName (assemblyPath)!);
+
+ var runtimeDir = Path.GetDirectoryName (typeof (object).Assembly.Location);
+ if (runtimeDir != null) {
+ resolver.AddSearchDirectory (runtimeDir);
+ }
+
+ var readerParams = new ReaderParameters { AssemblyResolver = resolver };
+ using var assembly = AssemblyDefinition.ReadAssembly (assemblyPath, readerParams);
+
+ var scanner = new Xamarin.Android.Tasks.XAJavaTypeScanner (
+ Xamarin.Android.Tools.AndroidTargetArch.Arm64,
+ new TaskLoggingHelper (new MockBuildEngine (), "test"),
+ cache
+ );
+
+ var javaTypes = scanner.GetJavaTypes (assembly);
+ var (dataSets, _) = Xamarin.Android.Tasks.TypeMapCecilAdapter.GetDebugNativeEntries (
+ javaTypes, cache, needUniqueAssemblies: false
+ );
+
+ var entries = dataSets.JavaToManaged
+ .Select (e => new TypeMapEntry (e.JavaName, e.ManagedName, e.SkipInJavaToManaged))
+ .OrderBy (e => e.JavaName, StringComparer.Ordinal)
+ .ThenBy (e => e.ManagedName, StringComparer.Ordinal)
+ .ToList ();
+
+ // Extract method-level [Register] attributes from each TypeDefinition.
+ // Use the raw javaTypes list to get ALL types — multiple managed types
+ // can map to the same JNI name (aliases).
+ var methodsByJavaName = new Dictionary> ();
+ foreach (var typeDef in javaTypes) {
+ var javaName = GetCecilJavaName (typeDef);
+ if (javaName == null) {
+ continue;
+ }
+
+ // Cecil uses '/' for nested types, SRM uses '+' (CLR format) — normalize
+ var managedName = $"{typeDef.FullName.Replace ('/', '+')}, {typeDef.Module.Assembly.Name.Name}";
+ var methods = ExtractMethodRegistrations (typeDef);
+
+ if (!methodsByJavaName.TryGetValue (javaName, out var groups)) {
+ groups = new List ();
+ methodsByJavaName [javaName] = groups;
+ }
+
+ groups.Add (new TypeMethodGroup (
+ managedName,
+ methods.OrderBy (m => m.JniName, StringComparer.Ordinal)
+ .ThenBy (m => m.JniSignature, StringComparer.Ordinal)
+ .ToList ()
+ ));
+ }
+
+ // Some types appear in dataSets.JavaToManaged (the typemap) but not in
+ // javaTypes (the raw list). Include them with empty method lists so the
+ // comparison covers all types known to the legacy scanner.
+ foreach (var entry in dataSets.JavaToManaged) {
+ if (methodsByJavaName.ContainsKey (entry.JavaName)) {
+ continue;
+ }
+
+ methodsByJavaName [entry.JavaName] = new List {
+ new TypeMethodGroup (entry.ManagedName, new List ())
+ };
+ }
+
+ return (entries, methodsByJavaName);
+ }
+
+ ///
+ /// Extracts the JNI type name from a Cecil TypeDefinition's [Register] attribute.
+ ///
+ static string? GetCecilJavaName (TypeDefinition typeDef)
+ {
+ if (!typeDef.HasCustomAttributes) {
+ return null;
+ }
+
+ foreach (var attr in typeDef.CustomAttributes) {
+ if (attr.AttributeType.FullName != "Android.Runtime.RegisterAttribute") {
+ continue;
+ }
+
+ if (attr.ConstructorArguments.Count > 0) {
+ return ((string) attr.ConstructorArguments [0].Value).Replace ('.', '/');
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Extracts all [Register] method registrations from a Cecil TypeDefinition.
+ /// Collects from both methods and properties.
+ ///
+ static List ExtractMethodRegistrations (TypeDefinition typeDef)
+ {
+ var methods = new List ();
+
+ // Collect [Register] from methods directly
+ foreach (var method in typeDef.Methods) {
+ if (!method.HasCustomAttributes) {
+ continue;
+ }
+
+ foreach (var attr in method.CustomAttributes) {
+ if (attr.AttributeType.FullName != "Android.Runtime.RegisterAttribute") {
+ continue;
+ }
+
+ if (attr.ConstructorArguments.Count < 2) {
+ continue;
+ }
+
+ var jniMethodName = (string) attr.ConstructorArguments [0].Value;
+ var jniSignature = (string) attr.ConstructorArguments [1].Value;
+ var connector = attr.ConstructorArguments.Count > 2
+ ? (string) attr.ConstructorArguments [2].Value
+ : null;
+
+ methods.Add (new MethodEntry (jniMethodName, jniSignature, connector));
+ }
+ }
+
+ // Collect [Register] from properties (attribute is on the property, not the getter/setter)
+ if (typeDef.HasProperties) {
+ foreach (var prop in typeDef.Properties) {
+ if (!prop.HasCustomAttributes) {
+ continue;
+ }
+
+ foreach (var attr in prop.CustomAttributes) {
+ if (attr.AttributeType.FullName != "Android.Runtime.RegisterAttribute") {
+ continue;
+ }
+
+ if (attr.ConstructorArguments.Count < 2) {
+ continue;
+ }
+
+ var jniMethodName = (string) attr.ConstructorArguments [0].Value;
+ var jniSignature = (string) attr.ConstructorArguments [1].Value;
+ var connector = attr.ConstructorArguments.Count > 2
+ ? (string) attr.ConstructorArguments [2].Value
+ : null;
+
+ methods.Add (new MethodEntry (jniMethodName, jniSignature, connector));
+ }
+ }
+ }
+
+ return methods;
+ }
+
+ ///
+ /// Runs the new SRM-based scanner on the given assembly.
+ /// Returns all types including aliases (multiple managed types per JNI name).
+ /// Scans all given assemblies but only returns types from the primary assembly (first path).
+ ///
+ static (List entries, Dictionary> methodsByJavaName) RunNewScanner (string[] assemblyPaths)
+ {
+ var primaryAssemblyName = Path.GetFileNameWithoutExtension (assemblyPaths [0]);
+ using var scanner = new JavaPeerScanner ();
+ var allPeers = scanner.Scan (assemblyPaths);
+ var peers = allPeers.Where (p => p.AssemblyName == primaryAssemblyName).ToList ();
+
+ var entries = peers
+ .Select (p => new TypeMapEntry (
+ p.JavaName,
+ $"{p.ManagedTypeName}, {p.AssemblyName}",
+ p.IsInterface || p.IsGenericDefinition
+ ))
+ .OrderBy (e => e.JavaName, StringComparer.Ordinal)
+ .ThenBy (e => e.ManagedName, StringComparer.Ordinal)
+ .ToList ();
+
+ var methodsByJavaName = new Dictionary> ();
+ foreach (var peer in peers) {
+ var managedName = $"{peer.ManagedTypeName}, {peer.AssemblyName}";
+
+ if (!methodsByJavaName.TryGetValue (peer.JavaName, out var groups)) {
+ groups = new List ();
+ methodsByJavaName [peer.JavaName] = groups;
+ }
+
+ groups.Add (new TypeMethodGroup (
+ managedName,
+ peer.MarshalMethods
+ .Select (m => new MethodEntry (m.JniName, m.JniSignature, m.Connector))
+ .OrderBy (m => m.JniName, StringComparer.Ordinal)
+ .ThenBy (m => m.JniSignature, StringComparer.Ordinal)
+ .ToList ()
+ ));
+ }
+
+ return (entries, methodsByJavaName);
+ }
+
+ ///
+ /// Verifies the new scanner produces the EXACT same JNI → managed type mapping
+ /// as the legacy scanner on Mono.Android.dll. Every entry must match: same JNI name,
+ /// same managed name, same skip flag. No extras, no missing entries.
+ ///
+ [Fact]
+ public void ExactTypeMap_MonoAndroid ()
+ {
+ var assemblyPath = MonoAndroidAssemblyPath;
+
+ var (legacy, _) = RunLegacyScanner (assemblyPath);
+ var (newEntries, _) = RunNewScanner (AllAssemblyPaths);
+
+ output.WriteLine ($"Legacy: {legacy.Count} entries, New: {newEntries.Count} entries");
+
+ // Build lookup: JavaName → list of entries (handles duplicates like java/lang/Object)
+ var legacyMap = legacy.GroupBy (e => e.JavaName).ToDictionary (g => g.Key, g => g.ToList ());
+ var newMap = newEntries.GroupBy (e => e.JavaName).ToDictionary (g => g.Key, g => g.ToList ());
+
+ var allJavaNames = new HashSet (legacyMap.Keys);
+ allJavaNames.UnionWith (newMap.Keys);
+
+ var missing = new List ();
+ var extra = new List ();
+ var managedNameMismatches = new List ();
+ var skipMismatches = new List ();
+
+ foreach (var javaName in allJavaNames.OrderBy (n => n)) {
+ var inLegacy = legacyMap.TryGetValue (javaName, out var legacyEntries);
+ var inNew = newMap.TryGetValue (javaName, out var newEntriesForName);
+
+ if (inLegacy && !inNew) {
+ foreach (var e in legacyEntries!) {
+ missing.Add ($"{e.JavaName} → {e.ManagedName} (skip={e.SkipInJavaToManaged})");
+ }
+ continue;
+ }
+
+ if (!inLegacy && inNew) {
+ foreach (var e in newEntriesForName!) {
+ extra.Add ($"{e.JavaName} → {e.ManagedName} (skip={e.SkipInJavaToManaged})");
+ }
+ continue;
+ }
+
+ // Both have this JNI name — compare managed names and skip flags
+ var legacySorted = legacyEntries!.OrderBy (e => e.ManagedName).ToList ();
+ var newSorted = newEntriesForName!.OrderBy (e => e.ManagedName).ToList ();
+
+ // Compare first entry (primary mapping) — that's what matters for the typemap
+ var le = legacySorted [0];
+ var ne = newSorted [0];
+
+ if (le.ManagedName != ne.ManagedName) {
+ managedNameMismatches.Add ($"{javaName}: legacy='{le.ManagedName}' new='{ne.ManagedName}'");
+ }
+
+ if (le.SkipInJavaToManaged != ne.SkipInJavaToManaged) {
+ skipMismatches.Add ($"{javaName}: legacy.skip={le.SkipInJavaToManaged} new.skip={ne.SkipInJavaToManaged}");
+ }
+ }
+
+ // Log all differences
+ if (missing.Count > 0) {
+ output.WriteLine ($"\n--- MISSING from new scanner ({missing.Count}) ---");
+ foreach (var m in missing) output.WriteLine ($" {m}");
+ }
+ if (extra.Count > 0) {
+ output.WriteLine ($"\n--- EXTRA in new scanner ({extra.Count}) ---");
+ foreach (var e in extra) output.WriteLine ($" {e}");
+ }
+ if (managedNameMismatches.Count > 0) {
+ output.WriteLine ($"\n--- MANAGED NAME MISMATCHES ({managedNameMismatches.Count}) ---");
+ foreach (var m in managedNameMismatches) output.WriteLine ($" {m}");
+ }
+ if (skipMismatches.Count > 0) {
+ output.WriteLine ($"\n--- SKIP FLAG MISMATCHES ({skipMismatches.Count}) ---");
+ foreach (var m in skipMismatches) output.WriteLine ($" {m}");
+ }
+
+ // All four must be empty for the test to pass
+ Assert.Empty (missing);
+ Assert.Empty (extra);
+ Assert.Empty (managedNameMismatches);
+ Assert.Empty (skipMismatches);
+ }
+
+ ///
+ /// Verifies the new scanner discovers the EXACT same set of managed types per JNI name
+ /// and the EXACT same marshal methods per managed type as the legacy scanner.
+ /// Multiple managed types can map to the same JNI name (aliases).
+ ///
+ [Fact]
+ public void ExactMarshalMethods_MonoAndroid ()
+ {
+ var assemblyPath = MonoAndroidAssemblyPath;
+
+ var (_, legacyMethods) = RunLegacyScanner (assemblyPath);
+ var (_, newMethods) = RunNewScanner (AllAssemblyPaths);
+
+ var legacyTypeCount = legacyMethods.Values.Sum (g => g.Count);
+ var newTypeCount = newMethods.Values.Sum (g => g.Count);
+ var legacyMethodCount = legacyMethods.Values.Sum (g => g.Sum (t => t.Methods.Count));
+ var newMethodCount = newMethods.Values.Sum (g => g.Sum (t => t.Methods.Count));
+ output.WriteLine ($"Legacy: {legacyTypeCount} type groups across {legacyMethods.Count} JNI names, {legacyMethodCount} total methods");
+ output.WriteLine ($"New: {newTypeCount} type groups across {newMethods.Count} JNI names, {newMethodCount} total methods");
+
+ var allJavaNames = new HashSet (legacyMethods.Keys);
+ allJavaNames.UnionWith (newMethods.Keys);
+
+ var missingTypes = new List ();
+ var extraTypes = new List ();
+ var missingMethods = new List ();
+ var extraMethods = new List ();
+ var connectorMismatches = new List ();
+
+ foreach (var javaName in allJavaNames.OrderBy (n => n)) {
+ var inLegacy = legacyMethods.TryGetValue (javaName, out var legacyGroups);
+ var inNew = newMethods.TryGetValue (javaName, out var newGroups);
+
+ if (inLegacy && !inNew) {
+ foreach (var g in legacyGroups!) {
+ missingTypes.Add ($"{javaName} → {g.ManagedName} ({g.Methods.Count} methods)");
+ }
+ continue;
+ }
+
+ if (!inLegacy && inNew) {
+ foreach (var g in newGroups!) {
+ extraTypes.Add ($"{javaName} → {g.ManagedName} ({g.Methods.Count} methods)");
+ }
+ continue;
+ }
+
+ // Both scanners found this JNI name — compare managed types within it
+ var legacyByManaged = legacyGroups!.ToDictionary (g => g.ManagedName, g => g.Methods);
+ var newByManaged = newGroups!.ToDictionary (g => g.ManagedName, g => g.Methods);
+
+ foreach (var managedName in legacyByManaged.Keys.Except (newByManaged.Keys)) {
+ missingTypes.Add ($"{javaName} → {managedName} ({legacyByManaged [managedName].Count} methods)");
+ }
+
+ foreach (var managedName in newByManaged.Keys.Except (legacyByManaged.Keys)) {
+ extraTypes.Add ($"{javaName} → {managedName} ({newByManaged [managedName].Count} methods)");
+ }
+
+ // For managed types present in both, compare their method sets
+ foreach (var managedName in legacyByManaged.Keys.Intersect (newByManaged.Keys)) {
+ var legacyMethodList = legacyByManaged [managedName];
+ var newMethodList = newByManaged [managedName];
+
+ var legacySet = new HashSet<(string name, string sig)> (
+ legacyMethodList.Select (m => (m.JniName, m.JniSignature))
+ );
+ var newSet = new HashSet<(string name, string sig)> (
+ newMethodList.Select (m => (m.JniName, m.JniSignature))
+ );
+
+ foreach (var m in legacySet.Except (newSet)) {
+ missingMethods.Add ($"{javaName} [{managedName}]: {m.name}{m.sig}");
+ }
+
+ foreach (var m in newSet.Except (legacySet)) {
+ extraMethods.Add ($"{javaName} [{managedName}]: {m.name}{m.sig}");
+ }
+
+ // For methods in both, compare connector strings
+ var legacyByKey = legacyMethodList
+ .GroupBy (m => (m.JniName, m.JniSignature))
+ .ToDictionary (g => g.Key, g => g.First ());
+ var newByKey = newMethodList
+ .GroupBy (m => (m.JniName, m.JniSignature))
+ .ToDictionary (g => g.Key, g => g.First ());
+
+ foreach (var key in legacyByKey.Keys.Intersect (newByKey.Keys)) {
+ var lc = legacyByKey [key].Connector ?? "";
+ var nc = newByKey [key].Connector ?? "";
+ if (lc != nc) {
+ connectorMismatches.Add ($"{javaName} [{managedName}]: {key.JniName}{key.JniSignature} legacy='{lc}' new='{nc}'");
+ }
+ }
+ }
+ }
+
+ // Log all differences
+ if (missingTypes.Count > 0) {
+ output.WriteLine ($"\n--- MANAGED TYPES MISSING from new scanner ({missingTypes.Count}) ---");
+ foreach (var m in missingTypes) output.WriteLine ($" {m}");
+ }
+ if (extraTypes.Count > 0) {
+ output.WriteLine ($"\n--- MANAGED TYPES EXTRA in new scanner ({extraTypes.Count}) ---");
+ foreach (var e in extraTypes) output.WriteLine ($" {e}");
+ }
+ if (missingMethods.Count > 0) {
+ output.WriteLine ($"\n--- METHODS MISSING from new scanner ({missingMethods.Count}) ---");
+ foreach (var m in missingMethods) output.WriteLine ($" {m}");
+ }
+ if (extraMethods.Count > 0) {
+ output.WriteLine ($"\n--- METHODS EXTRA in new scanner ({extraMethods.Count}) ---");
+ foreach (var e in extraMethods) output.WriteLine ($" {e}");
+ }
+ if (connectorMismatches.Count > 0) {
+ output.WriteLine ($"\n--- CONNECTOR MISMATCHES ({connectorMismatches.Count}) ---");
+ foreach (var m in connectorMismatches) output.WriteLine ($" {m}");
+ }
+
+ // All five categories must be empty — the new scanner should find the exact
+ // same managed types and methods per JNI name as the legacy scanner.
+ Assert.Empty (missingTypes);
+ Assert.Empty (extraTypes);
+ Assert.Empty (missingMethods);
+ Assert.Empty (extraMethods);
+ Assert.Empty (connectorMismatches);
+ }
+
+ [Fact]
+ public void ScannerDiagnostics_MonoAndroid ()
+ {
+ var assemblyPath = MonoAndroidAssemblyPath;
+
+ using var scanner = new JavaPeerScanner ();
+ var peers = scanner.Scan (new [] { assemblyPath });
+
+ var interfaces = peers.Count (p => p.IsInterface);
+ var abstracts = peers.Count (p => p.IsAbstract);
+ var generics = peers.Count (p => p.IsGenericDefinition);
+ var withMethods = peers.Count (p => p.MarshalMethods.Count > 0);
+ var totalMethods = peers.Sum (p => p.MarshalMethods.Count);
+ var withConstructors = peers.Count (p => p.MarshalMethods.Any (m => m.IsConstructor));
+ var withBase = peers.Count (p => p.BaseJavaName != null);
+ var withInterfaces = peers.Count (p => p.ImplementedInterfaceJavaNames.Count > 0);
+
+ output.WriteLine ($"Total types: {peers.Count}");
+ output.WriteLine ($"Interfaces: {interfaces}");
+ output.WriteLine ($"Abstract classes: {abstracts}");
+ output.WriteLine ($"Generic defs: {generics}");
+ output.WriteLine ($"With marshal methods: {withMethods} ({totalMethods} total methods)");
+ output.WriteLine ($"With constructors: {withConstructors}");
+ output.WriteLine ($"With base Java: {withBase}");
+ output.WriteLine ($"With interfaces: {withInterfaces}");
+
+ // Mono.Android.dll should have thousands of types
+ Assert.True (peers.Count > 3000, $"Expected >3000 types, got {peers.Count}");
+ Assert.True (interfaces > 500, $"Expected >500 interfaces, got {interfaces}");
+ Assert.True (totalMethods > 10000, $"Expected >10000 marshal methods, got {totalMethods}");
+ }
+
+ ///
+ /// Verifies the new scanner produces the same base Java type name for every type
+ /// as the legacy Cecil-based scanner. BaseJavaName drives the JCW "extends" clause
+ /// and is critical for correct Java inheritance.
+ ///
+ [Fact]
+ public void ExactBaseJavaNames_MonoAndroid ()
+ {
+ var assemblyPath = MonoAndroidAssemblyPath;
+
+ var (legacyData, _) = BuildLegacyTypeData (assemblyPath);
+ var newData = BuildNewTypeData (AllAssemblyPaths);
+
+ var allManagedNames = new HashSet (legacyData.Keys);
+ allManagedNames.IntersectWith (newData.Keys);
+
+ var mismatches = new List ();
+ int compared = 0;
+
+ foreach (var managedName in allManagedNames.OrderBy (n => n)) {
+ var legacy = legacyData [managedName];
+ var newInfo = newData [managedName];
+
+ compared++;
+
+ if (legacy.BaseJavaName != newInfo.BaseJavaName) {
+ // Legacy ToJniName can't resolve bases for open generic types (returns null).
+ // Our scanner resolves them correctly. Accept this known difference.
+ if (legacy.BaseJavaName == null && newInfo.BaseJavaName != null && managedName.Contains ('`')) {
+ continue;
+ }
+
+ // Invokers share JNI names with their base class. Legacy ToJniName
+ // self-reference filter discards the base (baseJni == javaName), but
+ // our scanner correctly resolves it. Accept legacy=null, new=valid
+ // for DoNotGenerateAcw types.
+ if (legacy.BaseJavaName == null && newInfo.BaseJavaName != null && newInfo.DoNotGenerateAcw) {
+ continue;
+ }
+
+ // Legacy ToJniName(System.Object) returns "java/lang/Object" as a fallback,
+ // making Java.Lang.Object/Throwable appear to have themselves as base.
+ // Our scanner correctly returns null. Accept legacy=self, new=null.
+ if (legacy.BaseJavaName != null && newInfo.BaseJavaName == null &&
+ legacy.BaseJavaName == legacy.JavaName) {
+ continue;
+ }
+
+ mismatches.Add ($"{managedName}: legacy='{legacy.BaseJavaName ?? "(null)"}' new='{newInfo.BaseJavaName ?? "(null)"}'");
+ }
+ }
+
+ output.WriteLine ($"Compared BaseJavaName for {compared} types");
+
+ if (mismatches.Count > 0) {
+ output.WriteLine ($"\n--- BASE JAVA NAME MISMATCHES ({mismatches.Count}) ---");
+ foreach (var m in mismatches) output.WriteLine ($" {m}");
+ }
+
+ Assert.Empty (mismatches);
+ }
+
+ ///
+ /// Verifies the new scanner produces the same implemented interface Java names
+ /// for every type as the legacy Cecil-based scanner. ImplementedInterfaceJavaNames
+ /// drives the JCW "implements" clause.
+ ///
+ [Fact]
+ public void ExactImplementedInterfaces_MonoAndroid ()
+ {
+ var assemblyPath = MonoAndroidAssemblyPath;
+
+ var (legacyData, _) = BuildLegacyTypeData (assemblyPath);
+ var newData = BuildNewTypeData (AllAssemblyPaths);
+
+ var allManagedNames = new HashSet (legacyData.Keys);
+ allManagedNames.IntersectWith (newData.Keys);
+
+ var missingInterfaces = new List ();
+ var extraInterfaces = new List ();
+ int compared = 0;
+
+ foreach (var managedName in allManagedNames.OrderBy (n => n)) {
+ var legacy = legacyData [managedName];
+ var newInfo = newData [managedName];
+
+ compared++;
+
+ var legacySet = new HashSet (legacy.ImplementedInterfaces, StringComparer.Ordinal);
+ var newSet = new HashSet (newInfo.ImplementedInterfaces, StringComparer.Ordinal);
+
+ foreach (var iface in legacySet.Except (newSet)) {
+ missingInterfaces.Add ($"{managedName}: missing '{iface}'");
+ }
+
+ foreach (var iface in newSet.Except (legacySet)) {
+ extraInterfaces.Add ($"{managedName}: extra '{iface}'");
+ }
+ }
+
+ output.WriteLine ($"Compared ImplementedInterfaces for {compared} types");
+
+ if (missingInterfaces.Count > 0) {
+ output.WriteLine ($"\n--- INTERFACES MISSING from new scanner ({missingInterfaces.Count}) ---");
+ foreach (var m in missingInterfaces) output.WriteLine ($" {m}");
+ }
+ if (extraInterfaces.Count > 0) {
+ output.WriteLine ($"\n--- INTERFACES EXTRA in new scanner ({extraInterfaces.Count}) ---");
+ foreach (var e in extraInterfaces) output.WriteLine ($" {e}");
+ }
+
+ Assert.Empty (missingInterfaces);
+ Assert.Empty (extraInterfaces);
+ }
+
+ ///
+ /// Verifies the new scanner resolves the same activation constructor info
+ /// for every type as the legacy Cecil-based scanner. This determines how
+ /// peer instances are created from Java handles.
+ ///
+ [Fact]
+ public void ExactActivationCtors_MonoAndroid ()
+ {
+ var assemblyPath = MonoAndroidAssemblyPath;
+
+ var (legacyData, _) = BuildLegacyTypeData (assemblyPath);
+ var newData = BuildNewTypeData (AllAssemblyPaths);
+
+ var allManagedNames = new HashSet (legacyData.Keys);
+ allManagedNames.IntersectWith (newData.Keys);
+
+ var presenceMismatches = new List ();
+ var declaringTypeMismatches = new List ();
+ var styleMismatches = new List ();
+ int compared = 0;
+ int withActivationCtor = 0;
+
+ foreach (var managedName in allManagedNames.OrderBy (n => n)) {
+ var legacy = legacyData [managedName];
+ var newInfo = newData [managedName];
+
+ compared++;
+
+ if (legacy.HasActivationCtor != newInfo.HasActivationCtor) {
+ presenceMismatches.Add ($"{managedName}: legacy.has={legacy.HasActivationCtor} new.has={newInfo.HasActivationCtor}");
+ continue;
+ }
+
+ if (!legacy.HasActivationCtor) {
+ continue;
+ }
+
+ withActivationCtor++;
+
+ if (legacy.ActivationCtorDeclaringType != newInfo.ActivationCtorDeclaringType) {
+ declaringTypeMismatches.Add ($"{managedName}: legacy='{legacy.ActivationCtorDeclaringType}' new='{newInfo.ActivationCtorDeclaringType}'");
+ }
+
+ if (legacy.ActivationCtorStyle != newInfo.ActivationCtorStyle) {
+ styleMismatches.Add ($"{managedName}: legacy='{legacy.ActivationCtorStyle}' new='{newInfo.ActivationCtorStyle}'");
+ }
+ }
+
+ output.WriteLine ($"Compared ActivationCtor for {compared} types ({withActivationCtor} have activation ctors)");
+
+ if (presenceMismatches.Count > 0) {
+ output.WriteLine ($"\n--- ACTIVATION CTOR PRESENCE MISMATCHES ({presenceMismatches.Count}) ---");
+ foreach (var m in presenceMismatches) output.WriteLine ($" {m}");
+ }
+ if (declaringTypeMismatches.Count > 0) {
+ output.WriteLine ($"\n--- ACTIVATION CTOR DECLARING TYPE MISMATCHES ({declaringTypeMismatches.Count}) ---");
+ foreach (var m in declaringTypeMismatches) output.WriteLine ($" {m}");
+ }
+ if (styleMismatches.Count > 0) {
+ output.WriteLine ($"\n--- ACTIVATION CTOR STYLE MISMATCHES ({styleMismatches.Count}) ---");
+ foreach (var m in styleMismatches) output.WriteLine ($" {m}");
+ }
+
+ Assert.Empty (presenceMismatches);
+ Assert.Empty (declaringTypeMismatches);
+ Assert.Empty (styleMismatches);
+ }
+
+ ///
+ /// Verifies the new scanner discovers the same Java constructors ([Register("<init>",...)])
+ /// for every type as the legacy Cecil-based scanner. These determine which nctor_N
+ /// native methods appear in JCW Java source files.
+ ///
+ [Fact]
+ public void ExactJavaConstructors_MonoAndroid ()
+ {
+ var assemblyPath = MonoAndroidAssemblyPath;
+
+ var (legacyData, _) = BuildLegacyTypeData (assemblyPath);
+ var newData = BuildNewTypeData (AllAssemblyPaths);
+
+ var allManagedNames = new HashSet (legacyData.Keys);
+ allManagedNames.IntersectWith (newData.Keys);
+
+ var missingCtors = new List ();
+ var extraCtors = new List ();
+ int compared = 0;
+ int totalCtors = 0;
+
+ foreach (var managedName in allManagedNames.OrderBy (n => n)) {
+ var legacy = legacyData [managedName];
+ var newInfo = newData [managedName];
+
+ compared++;
+
+ var legacySet = new HashSet (legacy.JavaConstructorSignatures, StringComparer.Ordinal);
+ var newSet = new HashSet (newInfo.JavaConstructorSignatures, StringComparer.Ordinal);
+ totalCtors += newSet.Count;
+
+ foreach (var sig in legacySet.Except (newSet)) {
+ missingCtors.Add ($"{managedName}: missing '{sig}'");
+ }
+
+ foreach (var sig in newSet.Except (legacySet)) {
+ extraCtors.Add ($"{managedName}: extra '{sig}'");
+ }
+ }
+
+ output.WriteLine ($"Compared JavaConstructors for {compared} types ({totalCtors} total constructors)");
+
+ if (missingCtors.Count > 0) {
+ output.WriteLine ($"\n--- JAVA CONSTRUCTORS MISSING from new scanner ({missingCtors.Count}) ---");
+ foreach (var m in missingCtors) output.WriteLine ($" {m}");
+ }
+ if (extraCtors.Count > 0) {
+ output.WriteLine ($"\n--- JAVA CONSTRUCTORS EXTRA in new scanner ({extraCtors.Count}) ---");
+ foreach (var e in extraCtors) output.WriteLine ($" {e}");
+ }
+
+ Assert.Empty (missingCtors);
+ Assert.Empty (extraCtors);
+ }
+
+ ///
+ /// Verifies the new scanner produces the same type-level flags as the legacy
+ /// Cecil-based scanner: IsInterface, IsAbstract, IsGenericDefinition, DoNotGenerateAcw.
+ ///
+ [Fact]
+ public void ExactTypeFlags_MonoAndroid ()
+ {
+ var assemblyPath = MonoAndroidAssemblyPath;
+
+ var (legacyData, _) = BuildLegacyTypeData (assemblyPath);
+ var newData = BuildNewTypeData (AllAssemblyPaths);
+
+ var allManagedNames = new HashSet (legacyData.Keys);
+ allManagedNames.IntersectWith (newData.Keys);
+
+ var interfaceMismatches = new List ();
+ var abstractMismatches = new List ();
+ var genericMismatches = new List ();
+ var acwMismatches = new List ();
+ int compared = 0;
+
+ foreach (var managedName in allManagedNames.OrderBy (n => n)) {
+ var legacy = legacyData [managedName];
+ var newInfo = newData [managedName];
+
+ compared++;
+
+ if (legacy.IsInterface != newInfo.IsInterface) {
+ interfaceMismatches.Add ($"{managedName}: legacy={legacy.IsInterface} new={newInfo.IsInterface}");
+ }
+
+ if (legacy.IsAbstract != newInfo.IsAbstract) {
+ abstractMismatches.Add ($"{managedName}: legacy={legacy.IsAbstract} new={newInfo.IsAbstract}");
+ }
+
+ if (legacy.IsGenericDefinition != newInfo.IsGenericDefinition) {
+ genericMismatches.Add ($"{managedName}: legacy={legacy.IsGenericDefinition} new={newInfo.IsGenericDefinition}");
+ }
+
+ if (legacy.DoNotGenerateAcw != newInfo.DoNotGenerateAcw) {
+ acwMismatches.Add ($"{managedName}: legacy={legacy.DoNotGenerateAcw} new={newInfo.DoNotGenerateAcw}");
+ }
+ }
+
+ output.WriteLine ($"Compared type flags for {compared} types");
+
+ if (interfaceMismatches.Count > 0) {
+ output.WriteLine ($"\n--- IsInterface MISMATCHES ({interfaceMismatches.Count}) ---");
+ foreach (var m in interfaceMismatches) output.WriteLine ($" {m}");
+ }
+ if (abstractMismatches.Count > 0) {
+ output.WriteLine ($"\n--- IsAbstract MISMATCHES ({abstractMismatches.Count}) ---");
+ foreach (var m in abstractMismatches) output.WriteLine ($" {m}");
+ }
+ if (genericMismatches.Count > 0) {
+ output.WriteLine ($"\n--- IsGenericDefinition MISMATCHES ({genericMismatches.Count}) ---");
+ foreach (var m in genericMismatches) output.WriteLine ($" {m}");
+ }
+ if (acwMismatches.Count > 0) {
+ output.WriteLine ($"\n--- DoNotGenerateAcw MISMATCHES ({acwMismatches.Count}) ---");
+ foreach (var m in acwMismatches) output.WriteLine ($" {m}");
+ }
+
+ Assert.Empty (interfaceMismatches);
+ Assert.Empty (abstractMismatches);
+ Assert.Empty (genericMismatches);
+ Assert.Empty (acwMismatches);
+ }
+
+ // ================================================================
+ // Shared data extraction helpers for comprehensive comparison tests
+ // ================================================================
+
+ ///
+ /// Unified per-type data record used for comparison between legacy and new scanners.
+ ///
+ record TypeComparisonData (
+ string ManagedName,
+ string JavaName,
+ string? BaseJavaName,
+ IReadOnlyList ImplementedInterfaces,
+ bool HasActivationCtor,
+ string? ActivationCtorDeclaringType,
+ string? ActivationCtorStyle,
+ IReadOnlyList JavaConstructorSignatures,
+ bool IsInterface,
+ bool IsAbstract,
+ bool IsGenericDefinition,
+ bool DoNotGenerateAcw
+ );
+
+ ///
+ /// Opens the assembly with Cecil and extracts per-type comparison data.
+ /// Keyed by managed type name "Namespace.Type, Assembly" for join with new scanner.
+ /// Returns both the per-type data and the type map entries for backward compatibility.
+ ///
+ static (Dictionary perType, List entries) BuildLegacyTypeData (string assemblyPath)
+ {
+ var cache = new TypeDefinitionCache ();
+ var resolver = new DefaultAssemblyResolver ();
+ resolver.AddSearchDirectory (Path.GetDirectoryName (assemblyPath)!);
+
+ var runtimeDir = Path.GetDirectoryName (typeof (object).Assembly.Location);
+ if (runtimeDir != null) {
+ resolver.AddSearchDirectory (runtimeDir);
+ }
+
+ var readerParams = new ReaderParameters { AssemblyResolver = resolver };
+ using var assembly = AssemblyDefinition.ReadAssembly (assemblyPath, readerParams);
+
+ var scanner = new Xamarin.Android.Tasks.XAJavaTypeScanner (
+ Xamarin.Android.Tools.AndroidTargetArch.Arm64,
+ new TaskLoggingHelper (new MockBuildEngine (), "test"),
+ cache
+ );
+
+ var javaTypes = scanner.GetJavaTypes (assembly);
+ var (dataSets, _) = Xamarin.Android.Tasks.TypeMapCecilAdapter.GetDebugNativeEntries (
+ javaTypes, cache, needUniqueAssemblies: false
+ );
+
+ var entries = dataSets.JavaToManaged
+ .Select (e => new TypeMapEntry (e.JavaName, e.ManagedName, e.SkipInJavaToManaged))
+ .OrderBy (e => e.JavaName, StringComparer.Ordinal)
+ .ThenBy (e => e.ManagedName, StringComparer.Ordinal)
+ .ToList ();
+
+ var perType = new Dictionary (StringComparer.Ordinal);
+
+ foreach (var typeDef in javaTypes) {
+ var javaName = GetCecilJavaName (typeDef);
+ if (javaName == null) {
+ continue;
+ }
+
+ // Cecil uses '/' for nested types, SRM uses '+' — normalize
+ var managedName = $"{typeDef.FullName.Replace ('/', '+')}, {typeDef.Module.Assembly.Name.Name}";
+
+ // Base Java name
+ string? baseJavaName = null;
+ var baseType = typeDef.GetBaseType (cache);
+ if (baseType != null) {
+ var baseJni = JavaNativeTypeManager.ToJniName (baseType, cache);
+ // Filter self-references: ToJniName can return the type's own JNI name
+ // (e.g., Java.Lang.Object → System.Object → "java/lang/Object").
+ if (baseJni != null && baseJni != javaName) {
+ baseJavaName = baseJni;
+ }
+ }
+
+ // Implemented interfaces (only Java peer interfaces with [Register])
+ var implementedInterfaces = new List ();
+ if (typeDef.HasInterfaces) {
+ foreach (var ifaceImpl in typeDef.Interfaces) {
+ var ifaceDef = cache.Resolve (ifaceImpl.InterfaceType);
+ if (ifaceDef == null) {
+ continue;
+ }
+ var ifaceRegs = CecilExtensions.GetTypeRegistrationAttributes (ifaceDef);
+ var ifaceReg = ifaceRegs.FirstOrDefault ();
+ if (ifaceReg != null) {
+ implementedInterfaces.Add (ifaceReg.Name.Replace ('.', '/'));
+ }
+ }
+ }
+ implementedInterfaces.Sort (StringComparer.Ordinal);
+
+ // Activation constructor
+ bool hasActivationCtor = false;
+ string? activationCtorDeclaringType = null;
+ string? activationCtorStyle = null;
+ FindLegacyActivationCtor (typeDef, cache, out hasActivationCtor, out activationCtorDeclaringType, out activationCtorStyle);
+
+ // Java constructors: [Register("", sig, ...)] on .ctor methods
+ var javaCtorSignatures = new List ();
+ foreach (var method in typeDef.Methods) {
+ if (!method.IsConstructor || method.IsStatic || !method.HasCustomAttributes) {
+ continue;
+ }
+ foreach (var attr in method.CustomAttributes) {
+ if (attr.AttributeType.FullName != "Android.Runtime.RegisterAttribute") {
+ continue;
+ }
+ if (attr.ConstructorArguments.Count >= 2) {
+ var regName = (string) attr.ConstructorArguments [0].Value;
+ if (regName == "" || regName == ".ctor") {
+ javaCtorSignatures.Add ((string) attr.ConstructorArguments [1].Value);
+ }
+ }
+ }
+ }
+ javaCtorSignatures.Sort (StringComparer.Ordinal);
+
+ // Type flags
+ var isInterface = typeDef.IsInterface;
+ var isAbstract = typeDef.IsAbstract && !typeDef.IsInterface;
+ var isGenericDefinition = typeDef.HasGenericParameters;
+ var doNotGenerateAcw = GetCecilDoNotGenerateAcw (typeDef);
+
+ perType [managedName] = new TypeComparisonData (
+ managedName,
+ javaName,
+ baseJavaName,
+ implementedInterfaces,
+ hasActivationCtor,
+ activationCtorDeclaringType,
+ activationCtorStyle,
+ javaCtorSignatures,
+ isInterface,
+ isAbstract,
+ isGenericDefinition,
+ doNotGenerateAcw
+ );
+ }
+
+ return (perType, entries);
+ }
+
+ ///
+ /// Walks the type hierarchy with Cecil to find the activation constructor.
+ /// XI-style: (IntPtr, JniHandleOwnership). JI-style: (ref JniObjectReference, JniObjectReferenceOptions).
+ ///
+ static void FindLegacyActivationCtor (TypeDefinition typeDef, TypeDefinitionCache cache,
+ out bool found, out string? declaringType, out string? style)
+ {
+ found = false;
+ declaringType = null;
+ style = null;
+
+ // Walk from current type up through base types
+ TypeDefinition? current = typeDef;
+ while (current != null) {
+ foreach (var method in current.Methods) {
+ if (!method.IsConstructor || method.IsStatic || method.Parameters.Count != 2) {
+ continue;
+ }
+
+ var p0 = method.Parameters [0].ParameterType.FullName;
+ var p1 = method.Parameters [1].ParameterType.FullName;
+
+ if (p0 == "System.IntPtr" && p1 == "Android.Runtime.JniHandleOwnership") {
+ found = true;
+ declaringType = $"{current.FullName.Replace ('/', '+')}, {current.Module.Assembly.Name.Name}";
+ style = "XamarinAndroid";
+ return;
+ }
+
+ if ((p0 == "Java.Interop.JniObjectReference&" || p0 == "Java.Interop.JniObjectReference") &&
+ p1 == "Java.Interop.JniObjectReferenceOptions") {
+ found = true;
+ declaringType = $"{current.FullName.Replace ('/', '+')}, {current.Module.Assembly.Name.Name}";
+ style = "JavaInterop";
+ return;
+ }
+ }
+
+ current = current.GetBaseType (cache);
+ }
+ }
+
+ ///
+ /// Gets DoNotGenerateAcw from a Cecil TypeDefinition's [Register] attribute.
+ ///
+ static bool GetCecilDoNotGenerateAcw (TypeDefinition typeDef)
+ {
+ if (!typeDef.HasCustomAttributes) {
+ return false;
+ }
+
+ foreach (var attr in typeDef.CustomAttributes) {
+ if (attr.AttributeType.FullName != "Android.Runtime.RegisterAttribute") {
+ continue;
+ }
+ if (attr.HasProperties) {
+ foreach (var prop in attr.Properties.Where (p => p.Name == "DoNotGenerateAcw")) {
+ if (prop.Argument.Value is bool val) {
+ return val;
+ }
+ }
+ }
+ // [Register] found but DoNotGenerateAcw not set — defaults to false
+ return false;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Runs the new SRM-based scanner and builds per-type comparison data.
+ /// Keyed by managed type name "Namespace.Type, Assembly" for join with legacy data.
+ /// Scans all given assemblies but only returns types from the primary assembly (first path).
+ ///
+ static Dictionary BuildNewTypeData (string[] assemblyPaths)
+ {
+ var primaryAssemblyName = Path.GetFileNameWithoutExtension (assemblyPaths [0]);
+ using var scanner = new JavaPeerScanner ();
+ var peers = scanner.Scan (assemblyPaths);
+
+ var perType = new Dictionary (StringComparer.Ordinal);
+
+ foreach (var peer in peers) {
+ // Only include types from the primary assembly
+ if (peer.AssemblyName != primaryAssemblyName) {
+ continue;
+ }
+
+ var managedName = $"{peer.ManagedTypeName}, {peer.AssemblyName}";
+
+ // Map ActivationCtor
+ bool hasActivationCtor = peer.ActivationCtor != null;
+ string? activationCtorDeclaringType = null;
+ string? activationCtorStyle = null;
+ if (peer.ActivationCtor != null) {
+ activationCtorDeclaringType = $"{peer.ActivationCtor.DeclaringTypeName}, {peer.ActivationCtor.DeclaringAssemblyName}";
+ activationCtorStyle = peer.ActivationCtor.Style.ToString ();
+ }
+
+ // Java constructor signatures (sorted) — derived from constructor marshal methods
+ var javaCtorSignatures = peer.MarshalMethods
+ .Where (m => m.IsConstructor)
+ .Select (m => m.JniSignature)
+ .OrderBy (s => s, StringComparer.Ordinal)
+ .ToList ();
+
+ // Implemented interfaces (sorted)
+ var implementedInterfaces = peer.ImplementedInterfaceJavaNames
+ .OrderBy (i => i, StringComparer.Ordinal)
+ .ToList ();
+
+ perType [managedName] = new TypeComparisonData (
+ managedName,
+ peer.JavaName,
+ peer.BaseJavaName,
+ implementedInterfaces,
+ hasActivationCtor,
+ activationCtorDeclaringType,
+ activationCtorStyle,
+ javaCtorSignatures,
+ peer.IsInterface,
+ peer.IsAbstract && !peer.IsInterface, // Match legacy: isAbstract excludes interfaces
+ peer.IsGenericDefinition,
+ peer.DoNotGenerateAcw
+ );
+ }
+
+ return perType;
+ }
+
+ ///
+ /// Gets the file path of the Mono.Android.dll ref assembly.
+ /// At compile time, nameof(Java.Lang.Object) verifies that the reference is correctly set up.
+ /// At runtime, we locate the assembly via the copy in the test output directory (placed there
+ /// by the _AddMonoAndroidReference MSBuild target with Private=true).
+ ///
+ static string MonoAndroidAssemblyPath {
+ get {
+ // Compile-time check: this ensures the Mono.Android reference is properly configured.
+ // It's never actually evaluated at runtime — it just validates the build setup.
+ _ = nameof (Java.Lang.Object);
+
+ // At runtime, find the Mono.Android.dll copy in the test output directory.
+ var testDir = Path.GetDirectoryName (typeof (ScannerComparisonTests).Assembly.Location)!;
+ var path = Path.Combine (testDir, "Mono.Android.dll");
+
+ if (!File.Exists (path)) {
+ throw new InvalidOperationException (
+ $"Mono.Android.dll not found at '{path}'. " +
+ "Ensure Mono.Android is built (bin/Debug/lib/packs/Microsoft.Android.Ref.*).");
+ }
+
+ return path;
+ }
+ }
+
+ ///
+ /// Gets all assembly paths needed for scanning: Mono.Android.dll + Java.Interop.dll.
+ /// Java.Interop.dll contains base types like JavaObject and JavaException that
+ /// Mono.Android types inherit from — without it, cross-assembly base resolution fails.
+ ///
+ static string[] AllAssemblyPaths {
+ get {
+ var monoAndroidPath = MonoAndroidAssemblyPath;
+ var dir = Path.GetDirectoryName (monoAndroidPath)!;
+ var javaInteropPath = Path.Combine (dir, "Java.Interop.dll");
+
+ if (!File.Exists (javaInteropPath)) {
+ return new [] { monoAndroidPath };
+ }
+
+ return new [] { monoAndroidPath, javaInteropPath };
+ }
+ }
+
+ ///
+ /// Normalizes a CRC64 JNI package name so that different CRC64 implementations
+ /// compare as equal. Replaces "crc64XXXX/TypeName" with "crc64.../TypeName".
+ /// Non-CRC64 names (e.g., "android/app/Activity") are returned unchanged.
+ ///
+ static string NormalizeCrc64 (string javaName)
+ {
+ if (javaName.StartsWith ("crc64", StringComparison.Ordinal)) {
+ int slash = javaName.IndexOf ('/');
+ if (slash > 0) {
+ return "crc64.../" + javaName.Substring (slash + 1);
+ }
+ }
+ return javaName;
+ }
+
+ ///
+ /// Path to the UserTypesFixture.dll — a test assembly that references real Mono.Android
+ /// and contains user types with [Activity], [Service], [Register], nested types, etc.
+ ///
+ static string? UserTypesFixturePath {
+ get {
+ var testDir = Path.GetDirectoryName (typeof (ScannerComparisonTests).Assembly.Location)!;
+ var path = Path.Combine (testDir, "UserTypesFixture.dll");
+ return File.Exists (path) ? path : null;
+ }
+ }
+
+ ///
+ /// All assembly paths needed for scanning UserTypesFixture.dll:
+ /// the fixture itself + Mono.Android.dll + Java.Interop.dll for base type resolution.
+ ///
+ static string[]? AllUserTypesAssemblyPaths {
+ get {
+ var fixturePath = UserTypesFixturePath;
+ if (fixturePath == null) {
+ return null;
+ }
+
+ var dir = Path.GetDirectoryName (fixturePath)!;
+ var monoAndroidPath = Path.Combine (dir, "Mono.Android.dll");
+ var javaInteropPath = Path.Combine (dir, "Java.Interop.dll");
+
+ var paths = new List { fixturePath };
+ if (File.Exists (monoAndroidPath)) {
+ paths.Add (monoAndroidPath);
+ }
+ if (File.Exists (javaInteropPath)) {
+ paths.Add (javaInteropPath);
+ }
+ return paths.ToArray ();
+ }
+ }
+
+ ///
+ /// Verifies the new scanner produces the EXACT same JNI → managed type mapping
+ /// as the legacy scanner on UserTypesFixture.dll — a user-type assembly with
+ /// [Activity], [Service], [BroadcastReceiver], [Application], [Register], [Export],
+ /// nested types, and plain Java.Lang.Object subclasses (CRC64 names).
+ ///
+ [Fact]
+ public void ExactTypeMap_UserTypesFixture ()
+ {
+ var paths = AllUserTypesAssemblyPaths;
+ Assert.NotNull (paths);
+
+ var fixturePath = paths! [0];
+ var (legacy, _) = RunLegacyScanner (fixturePath);
+ var (newEntries, _) = RunNewScanner (paths);
+
+ output.WriteLine ($"UserTypesFixture: Legacy={legacy.Count} entries, New={newEntries.Count} entries");
+
+ // Normalize CRC64 hashes — the two scanners use different CRC64 implementations
+ // but the type name part after the hash must match.
+ var legacyNormalized = legacy.Select (e => e with { JavaName = NormalizeCrc64 (e.JavaName) }).ToList ();
+ var newNormalized = newEntries.Select (e => e with { JavaName = NormalizeCrc64 (e.JavaName) }).ToList ();
+
+ var legacyMap = legacyNormalized.GroupBy (e => e.JavaName).ToDictionary (g => g.Key, g => g.ToList ());
+ var newMap = newNormalized.GroupBy (e => e.JavaName).ToDictionary (g => g.Key, g => g.ToList ());
+
+ var allJavaNames = new HashSet (legacyMap.Keys);
+ allJavaNames.UnionWith (newMap.Keys);
+
+ var missing = new List ();
+ var extra = new List ();
+ var managedNameMismatches = new List ();
+ var skipMismatches = new List ();
+
+ foreach (var javaName in allJavaNames.OrderBy (n => n)) {
+ var inLegacy = legacyMap.TryGetValue (javaName, out var legacyEntries);
+ var inNew = newMap.TryGetValue (javaName, out var newEntriesForName);
+
+ if (inLegacy && !inNew) {
+ foreach (var e in legacyEntries!) {
+ missing.Add ($"{e.JavaName} → {e.ManagedName} (skip={e.SkipInJavaToManaged})");
+ }
+ continue;
+ }
+
+ if (!inLegacy && inNew) {
+ foreach (var e in newEntriesForName!) {
+ extra.Add ($"{e.JavaName} → {e.ManagedName} (skip={e.SkipInJavaToManaged})");
+ }
+ continue;
+ }
+
+ var legacySorted = legacyEntries!.OrderBy (e => e.ManagedName).ToList ();
+ var newSorted = newEntriesForName!.OrderBy (e => e.ManagedName).ToList ();
+
+ var le = legacySorted [0];
+ var ne = newSorted [0];
+
+ if (le.ManagedName != ne.ManagedName) {
+ managedNameMismatches.Add ($"{javaName}: legacy='{le.ManagedName}' new='{ne.ManagedName}'");
+ }
+
+ if (le.SkipInJavaToManaged != ne.SkipInJavaToManaged) {
+ skipMismatches.Add ($"{javaName}: legacy.skip={le.SkipInJavaToManaged} new.skip={ne.SkipInJavaToManaged}");
+ }
+ }
+
+ if (missing.Count > 0) {
+ output.WriteLine ($"\n--- MISSING from new scanner ({missing.Count}) ---");
+ foreach (var m in missing) output.WriteLine ($" {m}");
+ }
+ if (extra.Count > 0) {
+ output.WriteLine ($"\n--- EXTRA in new scanner ({extra.Count}) ---");
+ foreach (var e in extra) output.WriteLine ($" {e}");
+ }
+ if (managedNameMismatches.Count > 0) {
+ output.WriteLine ($"\n--- MANAGED NAME MISMATCHES ({managedNameMismatches.Count}) ---");
+ foreach (var m in managedNameMismatches) output.WriteLine ($" {m}");
+ }
+ if (skipMismatches.Count > 0) {
+ output.WriteLine ($"\n--- SKIP FLAG MISMATCHES ({skipMismatches.Count}) ---");
+ foreach (var m in skipMismatches) output.WriteLine ($" {m}");
+ }
+
+ Assert.Empty (missing);
+ Assert.Empty (managedNameMismatches);
+ Assert.Empty (skipMismatches);
+ Assert.Empty (extra);
+ }
+
+ ///
+ /// Verifies the new scanner produces the same marshal methods (type-level and method-level
+ /// [Register] attributes) as the legacy scanner on UserTypesFixture.dll.
+ ///
+ [Fact]
+ public void ExactMarshalMethods_UserTypesFixture ()
+ {
+ var paths = AllUserTypesAssemblyPaths;
+ Assert.NotNull (paths);
+
+ var fixturePath = paths! [0];
+ var (_, legacyMethods) = RunLegacyScanner (fixturePath);
+ var (_, newMethods) = RunNewScanner (paths);
+
+ // Normalize CRC64 hashes in method group keys
+ var legacyNormalized = legacyMethods
+ .ToDictionary (kvp => NormalizeCrc64 (kvp.Key), kvp => kvp.Value);
+ var newNormalized = newMethods
+ .ToDictionary (kvp => NormalizeCrc64 (kvp.Key), kvp => kvp.Value);
+
+ output.WriteLine ($"UserTypesFixture: Legacy={legacyNormalized.Count} types with methods, New={newNormalized.Count}");
+
+ // Only compare types that the legacy scanner found (it skips user types without [Register])
+ var missing = new List ();
+ var methodMismatches = new List ();
+
+ foreach (var javaName in legacyNormalized.Keys.OrderBy (n => n)) {
+ if (!newNormalized.TryGetValue (javaName, out var newGroups)) {
+ missing.Add (javaName);
+ continue;
+ }
+
+ var legacyGroups = legacyNormalized [javaName];
+
+ foreach (var legacyGroup in legacyGroups) {
+ var newGroup = newGroups.FirstOrDefault (g => g.ManagedName == legacyGroup.ManagedName);
+ if (newGroup == null) {
+ missing.Add ($"{javaName} → {legacyGroup.ManagedName}");
+ continue;
+ }
+
+ // Legacy test helper only extracts [Register] methods, not [Export] methods.
+ // When legacy has 0 methods (from the typemap fallback path) but new has some,
+ // the new scanner is correct — it handles [Export] too. Skip comparison.
+ if (legacyGroup.Methods.Count == 0) {
+ continue;
+ }
+
+ if (legacyGroup.Methods.Count != newGroup.Methods.Count) {
+ methodMismatches.Add ($"{javaName}/{legacyGroup.ManagedName}: legacy={legacyGroup.Methods.Count} methods, new={newGroup.Methods.Count}");
+ continue;
+ }
+
+ for (int i = 0; i < legacyGroup.Methods.Count; i++) {
+ var lm = legacyGroup.Methods [i];
+ var nm = newGroup.Methods [i];
+ if (lm.JniName != nm.JniName || lm.JniSignature != nm.JniSignature) {
+ methodMismatches.Add ($"{javaName}: [{i}] legacy=({lm.JniName}, {lm.JniSignature}) new=({nm.JniName}, {nm.JniSignature})");
+ }
+ }
+ }
+ }
+
+ if (missing.Count > 0) {
+ output.WriteLine ($"\n--- MISSING from new scanner ({missing.Count}) ---");
+ foreach (var m in missing) output.WriteLine ($" {m}");
+ }
+ if (methodMismatches.Count > 0) {
+ output.WriteLine ($"\n--- METHOD MISMATCHES ({methodMismatches.Count}) ---");
+ foreach (var m in methodMismatches) output.WriteLine ($" {m}");
+ }
+
+ Assert.Empty (missing);
+ Assert.Empty (methodMismatches);
+ }
+}
diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests/UserTypesFixture/UserTypes.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests/UserTypesFixture/UserTypes.cs
new file mode 100644
index 00000000000..291137278cb
--- /dev/null
+++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests/UserTypesFixture/UserTypes.cs
@@ -0,0 +1,176 @@
+// User-type test fixture assembly that references REAL Mono.Android.
+// Exercises edge cases that MCW binding assemblies don't have:
+// - User types extending Java peers without [Register]
+// - Component attributes ([Activity], [Service], etc.)
+// - [Export] methods
+// - Nested user types
+// - Generic user types
+
+using System;
+using System.Runtime.Versioning;
+using Android.App;
+using Android.Content;
+using Android.Runtime;
+using Java.Interop;
+
+[assembly: SupportedOSPlatform ("android21.0")]
+
+// --- User Activity with explicit Name ---
+
+namespace UserApp
+{
+ [Activity (Name = "com.example.userapp.MainActivity", MainLauncher = true, Label = "User App")]
+ public class MainActivity : Activity
+ {
+ protected override void OnCreate (Android.OS.Bundle? savedInstanceState)
+ {
+ base.OnCreate (savedInstanceState);
+ }
+ }
+
+ // Activity WITHOUT explicit Name — should get CRC64-based JNI name
+ [Activity (Label = "Settings")]
+ public class SettingsActivity : Activity
+ {
+ }
+
+ // Simple Activity subclass — no attributes at all, just extends a Java peer
+ public class PlainActivity : Activity
+ {
+ }
+}
+
+// --- Services ---
+
+namespace UserApp.Services
+{
+ [Service (Name = "com.example.userapp.MyBackgroundService")]
+ public class MyBackgroundService : Android.App.Service
+ {
+ public override Android.OS.IBinder? OnBind (Android.Content.Intent? intent) => null;
+ }
+
+ // Service without explicit Name
+ [Service]
+ public class UnnamedService : Android.App.Service
+ {
+ public override Android.OS.IBinder? OnBind (Android.Content.Intent? intent) => null;
+ }
+}
+
+// --- BroadcastReceiver ---
+
+namespace UserApp.Receivers
+{
+ [BroadcastReceiver (Name = "com.example.userapp.BootReceiver", Exported = false)]
+ public class BootReceiver : BroadcastReceiver
+ {
+ public override void OnReceive (Context? context, Intent? intent)
+ {
+ }
+ }
+}
+
+// --- Application with BackupAgent ---
+
+namespace UserApp
+{
+ public class MyBackupAgent : Android.App.Backup.BackupAgent
+ {
+ public override void OnBackup (Android.OS.ParcelFileDescriptor? oldState,
+ Android.App.Backup.BackupDataOutput? data,
+ Android.OS.ParcelFileDescriptor? newState)
+ {
+ }
+
+ public override void OnRestore (Android.App.Backup.BackupDataInput? data,
+ int appVersionCode,
+ Android.OS.ParcelFileDescriptor? newState)
+ {
+ }
+ }
+
+ [Application (Name = "com.example.userapp.MyApp", BackupAgent = typeof (MyBackupAgent))]
+ public class MyApp : Application
+ {
+ public MyApp (IntPtr handle, JniHandleOwnership transfer)
+ : base (handle, transfer)
+ {
+ }
+ }
+}
+
+// --- Nested types ---
+
+namespace UserApp.Nested
+{
+ [Register ("com/example/userapp/OuterClass")]
+ public class OuterClass : Java.Lang.Object
+ {
+ // Nested class inheriting from Java peer — no [Register]
+ public class InnerHelper : Java.Lang.Object
+ {
+ }
+
+ // Deeply nested
+ public class MiddleClass : Java.Lang.Object
+ {
+ public class DeepHelper : Java.Lang.Object
+ {
+ }
+ }
+ }
+}
+
+// --- Plain Java.Lang.Object subclasses (no attributes) ---
+
+namespace UserApp.Models
+{
+ // These should all get CRC64-based JNI names
+ public class UserModel : Java.Lang.Object
+ {
+ }
+
+ public class DataManager : Java.Lang.Object
+ {
+ }
+}
+
+// --- Explicit [Register] on user type ---
+
+namespace UserApp
+{
+ [Register ("com/example/userapp/CustomView")]
+ public class CustomView : Android.Views.View
+ {
+ protected CustomView (IntPtr handle, JniHandleOwnership transfer)
+ : base (handle, transfer)
+ {
+ }
+ }
+}
+
+// --- Interface implementation ---
+
+namespace UserApp.Listeners
+{
+ public class MyClickListener : Java.Lang.Object, Android.Views.View.IOnClickListener
+ {
+ public void OnClick (Android.Views.View? v)
+ {
+ }
+ }
+}
+
+// --- [Export] method ---
+
+namespace UserApp
+{
+ public class ExportedMethodHolder : Java.Lang.Object
+ {
+ [Export ("doWork")]
+ public void DoWork ()
+ {
+ }
+ }
+}
diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests/UserTypesFixture/UserTypesFixture.csproj b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests/UserTypesFixture/UserTypesFixture.csproj
new file mode 100644
index 00000000000..bba3496f276
--- /dev/null
+++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.IntegrationTests/UserTypesFixture/UserTypesFixture.csproj
@@ -0,0 +1,45 @@
+
+
+
+
+ $(DotNetTargetFramework)
+ latest
+ enable
+ false
+ Library
+ true
+ ..\..\..\product.snk
+
+ ..\..\..\bin\Test$(Configuration)\
+
+
+
+
+
+
+
+
+
+ <_MonoAndroidRefCandidate Include="$(MSBuildThisFileDirectory)..\..\..\bin\$(Configuration)\lib\packs\Microsoft.Android.Ref.*\*\ref\net*\Mono.Android.dll" />
+ <_JavaInteropRefCandidate Include="$(MSBuildThisFileDirectory)..\..\..\bin\$(Configuration)\lib\packs\Microsoft.Android.Ref.*\*\ref\net*\Java.Interop.dll" />
+
+
+ <_MonoAndroidRefAssembly>@(_MonoAndroidRefCandidate, ';')
+ <_MonoAndroidRefAssembly>$(_MonoAndroidRefAssembly.Split(';')[0])
+ <_JavaInteropRefAssembly>@(_JavaInteropRefCandidate, ';')
+ <_JavaInteropRefAssembly>$(_JavaInteropRefAssembly.Split(';')[0])
+
+
+
+ $(_MonoAndroidRefAssembly)
+
+
+
+
+ $(_JavaInteropRefAssembly)
+
+
+
+
+