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) + + + + +