From b119060a074a1910ed01224ec66a956493d3d2f6 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 16 Feb 2026 16:42:43 +0100 Subject: [PATCH 1/5] [TrimmableTypeMap] Test fixtures and foundational scanner tests Add test infrastructure for the Java peer scanner: - TestFixtures project with stub Mono.Android attributes ([Register], [Activity], [Service], etc.) and test types covering MCW bindings, user types, generics, nested types, interfaces, and component types - JavaPeerScannerTests with test helpers (ScanFixtures, FindByJavaName) and foundational assertions: type discovery, DoNotGenerateAcw flags, component unconditional marking, interface/abstract/generic metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...rosoft.Android.Sdk.TrimmableTypeMap.csproj | 1 + ....Android.Sdk.TrimmableTypeMap.Tests.csproj | 38 ++ .../Scanner/JavaPeerScannerTests.cs | 109 ++++++ .../TestFixtures/StubAttributes.cs | 119 ++++++ .../TestFixtures/TestFixtures.csproj | 13 + .../TestFixtures/TestTypes.cs | 341 ++++++++++++++++++ 6 files changed, 621 insertions(+) create mode 100644 tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests.csproj create mode 100644 tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.cs create mode 100644 tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/StubAttributes.cs create mode 100644 tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestFixtures.csproj create mode 100644 tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestTypes.cs diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Microsoft.Android.Sdk.TrimmableTypeMap.csproj b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Microsoft.Android.Sdk.TrimmableTypeMap.csproj index d75d4d7df1e..bf04c5efde8 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Microsoft.Android.Sdk.TrimmableTypeMap.csproj +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Microsoft.Android.Sdk.TrimmableTypeMap.csproj @@ -11,6 +11,7 @@ + diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests.csproj b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests.csproj new file mode 100644 index 00000000000..6370a77e680 --- /dev/null +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests.csproj @@ -0,0 +1,38 @@ + + + + $(DotNetStableTargetFramework) + latest + enable + false + Microsoft.Android.Sdk.TrimmableTypeMap.Tests + + + + + + + + + + + + + + + + + + false + + + + + + + <_TestFixtureFiles Include="TestFixtures\bin\$(Configuration)\$(DotNetStableTargetFramework)\TestFixtures.dll" /> + + + + + diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.cs new file mode 100644 index 00000000000..bc2b6195f22 --- /dev/null +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Xunit; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap.Tests; + +public partial class JavaPeerScannerTests +{ + static string TestFixtureAssemblyPath { + get { + var testAssemblyDir = Path.GetDirectoryName (typeof (JavaPeerScannerTests).Assembly.Location)!; + var fixtureAssembly = Path.Combine (testAssemblyDir, "TestFixtures.dll"); + Assert.True (File.Exists (fixtureAssembly), + $"TestFixtures.dll not found at {fixtureAssembly}. Ensure the TestFixtures project builds."); + return fixtureAssembly; + } + } + + List ScanFixtures () + { + using var scanner = new JavaPeerScanner (); + return scanner.Scan (new [] { TestFixtureAssemblyPath }); + } + + JavaPeerInfo FindByJavaName (List peers, string javaName) + { + var peer = peers.FirstOrDefault (p => p.JavaName == javaName); + Assert.NotNull (peer); + return peer; + } + + JavaPeerInfo FindByManagedName (List peers, string managedName) + { + var peer = peers.FirstOrDefault (p => p.ManagedTypeName == managedName); + Assert.NotNull (peer); + return peer; + } + + [Fact] + public void Scan_FindsAllJavaPeerTypes () + { + var peers = ScanFixtures (); + Assert.NotEmpty (peers); + Assert.Contains (peers, p => p.JavaName == "java/lang/Object"); + Assert.Contains (peers, p => p.JavaName == "android/app/Activity"); + Assert.Contains (peers, p => p.JavaName == "my/app/MainActivity"); + } + + [Theory] + [InlineData ("android/app/Activity", true)] + [InlineData ("android/widget/Button", true)] + [InlineData ("my/app/MainActivity", false)] + public void Scan_DoNotGenerateAcw (string javaName, bool expected) + { + var peers = ScanFixtures (); + Assert.Equal (expected, FindByJavaName (peers, javaName).DoNotGenerateAcw); + } + + [Theory] + [InlineData ("my/app/MainActivity", true)] + [InlineData ("my/app/MyService", true)] + [InlineData ("my/app/MyReceiver", true)] + [InlineData ("my/app/MyProvider", true)] + [InlineData ("my/app/MyApplication", true)] + [InlineData ("my/app/MyInstrumentation", true)] + [InlineData ("my/app/MyBackupAgent", true)] + [InlineData ("my/app/MyManageSpaceActivity", true)] + [InlineData ("my/app/MyHelper", false)] + [InlineData ("android/app/Activity", false)] + public void Scan_IsUnconditional (string javaName, bool expected) + { + var peers = ScanFixtures (); + Assert.Equal (expected, FindByJavaName (peers, javaName).IsUnconditional); + } + + [Fact] + public void Scan_TypeMetadata_IsCorrect () + { + var peers = ScanFixtures (); + Assert.True (FindByJavaName (peers, "my/app/AbstractBase").IsAbstract); + Assert.True (FindByManagedName (peers, "Android.Views.IOnClickListener").IsInterface); + Assert.False (FindByManagedName (peers, "Android.Views.IOnClickListener").DoNotGenerateAcw); + + var generic = FindByJavaName (peers, "my/app/GenericHolder"); + Assert.True (generic.IsGenericDefinition); + Assert.Equal ("MyApp.Generic.GenericHolder`1", generic.ManagedTypeName); + } + + [Fact] + public void Scan_InvokerAndInterface_ShareJavaName () + { + var peers = ScanFixtures (); + var clickListenerPeers = peers.Where (p => p.JavaName == "android/view/View$OnClickListener").ToList (); + Assert.Equal (2, clickListenerPeers.Count); + Assert.Contains (clickListenerPeers, p => p.IsInterface); + Assert.Contains (clickListenerPeers, p => p.DoNotGenerateAcw); + } + + [Fact] + public void Scan_AllTypes_HaveAssemblyName () + { + var peers = ScanFixtures (); + Assert.All (peers, peer => + Assert.False (string.IsNullOrEmpty (peer.AssemblyName), + $"Type {peer.ManagedTypeName} should have assembly name")); + } +} diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/StubAttributes.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/StubAttributes.cs new file mode 100644 index 00000000000..36c7587eb28 --- /dev/null +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/StubAttributes.cs @@ -0,0 +1,119 @@ +using System; + +namespace Java.Interop +{ + public interface IJniNameProviderAttribute + { + string Name { get; } + } +} + +namespace Android.Runtime +{ + [AttributeUsage ( + AttributeTargets.Class | AttributeTargets.Constructor | AttributeTargets.Field | + AttributeTargets.Interface | AttributeTargets.Method | AttributeTargets.Property, + AllowMultiple = false)] + public sealed class RegisterAttribute : Attribute, Java.Interop.IJniNameProviderAttribute + { + public string Name { get; } + public string? Signature { get; set; } + public string? Connector { get; set; } + public bool DoNotGenerateAcw { get; set; } + public int ApiSince { get; set; } + + public RegisterAttribute (string name) => Name = name; + + public RegisterAttribute (string name, string signature, string connector) + { + Name = name; + Signature = signature; + Connector = connector; + } + } + + public enum JniHandleOwnership + { + DoNotTransfer = 0, + TransferLocalRef = 1, + TransferGlobalRef = 2, + } +} + +namespace Android.App +{ + [AttributeUsage (AttributeTargets.Class)] + public sealed class ActivityAttribute : Attribute, Java.Interop.IJniNameProviderAttribute + { + public bool MainLauncher { get; set; } + public string? Label { get; set; } + public string? Icon { get; set; } + public string? Name { get; set; } + string Java.Interop.IJniNameProviderAttribute.Name => Name ?? ""; + } + + [AttributeUsage (AttributeTargets.Class)] + public sealed class ServiceAttribute : Attribute, Java.Interop.IJniNameProviderAttribute + { + public string? Name { get; set; } + string Java.Interop.IJniNameProviderAttribute.Name => Name ?? ""; + } + + [AttributeUsage (AttributeTargets.Class)] + public sealed class InstrumentationAttribute : Attribute, Java.Interop.IJniNameProviderAttribute + { + public string? Name { get; set; } + string Java.Interop.IJniNameProviderAttribute.Name => Name ?? ""; + } + + [AttributeUsage (AttributeTargets.Class)] + public sealed class ApplicationAttribute : Attribute, Java.Interop.IJniNameProviderAttribute + { + public Type? BackupAgent { get; set; } + public Type? ManageSpaceActivity { get; set; } + public string? Name { get; set; } + string Java.Interop.IJniNameProviderAttribute.Name => Name ?? ""; + } +} + +namespace Android.Content +{ + [AttributeUsage (AttributeTargets.Class)] + public sealed class BroadcastReceiverAttribute : Attribute, Java.Interop.IJniNameProviderAttribute + { + public string? Name { get; set; } + string Java.Interop.IJniNameProviderAttribute.Name => Name ?? ""; + } + + [AttributeUsage (AttributeTargets.Class)] + public sealed class ContentProviderAttribute : Attribute, Java.Interop.IJniNameProviderAttribute + { + public string []? Authorities { get; set; } + public string? Name { get; set; } + string Java.Interop.IJniNameProviderAttribute.Name => Name ?? ""; + + public ContentProviderAttribute (string [] authorities) => Authorities = authorities; + } +} + +namespace Java.Interop +{ + [AttributeUsage (AttributeTargets.Method, AllowMultiple = false)] + public sealed class ExportAttribute : Attribute + { + public string? Name { get; set; } + + public ExportAttribute () { } + public ExportAttribute (string name) => Name = name; + } +} + +namespace MyApp +{ + [AttributeUsage (AttributeTargets.Class)] + public sealed class CustomJniNameAttribute : Attribute, Java.Interop.IJniNameProviderAttribute + { + public string Name { get; } + public CustomJniNameAttribute (string name) => Name = name; + } +} diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestFixtures.csproj b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestFixtures.csproj new file mode 100644 index 00000000000..f7f4c72139b --- /dev/null +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestFixtures.csproj @@ -0,0 +1,13 @@ + + + + $(DotNetStableTargetFramework) + latest + enable + Microsoft.Android.Sdk.TrimmableTypeMap.Tests.TestFixtures + + false + true + + + diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestTypes.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestTypes.cs new file mode 100644 index 00000000000..35987f36f93 --- /dev/null +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestTypes.cs @@ -0,0 +1,341 @@ +using System; +using Android.App; +using Android.Content; +using Android.Runtime; + +namespace Java.Lang +{ + [Register ("java/lang/Object", DoNotGenerateAcw = true)] + public class Object + { + public Object () { } + protected Object (IntPtr handle, JniHandleOwnership transfer) { } + } + + [Register ("java/lang/Throwable", DoNotGenerateAcw = true)] + public class Throwable : Object + { + protected Throwable (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + + [Register ("getMessage", "()Ljava/lang/String;", "GetGetMessageHandler")] + public virtual string? Message { get; } + } + + [Register ("java/lang/Exception", DoNotGenerateAcw = true)] + public class Exception : Throwable + { + protected Exception (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + } +} + +namespace Android.App +{ + [Register ("android/app/Activity", DoNotGenerateAcw = true)] + public class Activity : Java.Lang.Object + { + public Activity () { } + protected Activity (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + + [Register ("onCreate", "(Landroid/os/Bundle;)V", "GetOnCreate_Landroid_os_Bundle_Handler")] + protected virtual void OnCreate (object? savedInstanceState) { } + + [Register ("onStart", "()V", "")] + protected virtual void OnStart () { } + } + + [Register ("android/app/Service", DoNotGenerateAcw = true)] + public class Service : Java.Lang.Object + { + protected Service (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + } +} + +namespace Android.App.Backup +{ + [Register ("android/app/backup/BackupAgent", DoNotGenerateAcw = true)] + public class BackupAgent : Java.Lang.Object + { + protected BackupAgent (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + } +} + +namespace Android.Content +{ + [Register ("android/content/Context", DoNotGenerateAcw = true)] + public class Context : Java.Lang.Object + { + protected Context (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + } +} + +namespace Android.Views +{ + [Register ("android/view/View", DoNotGenerateAcw = true)] + public class View : Java.Lang.Object + { + protected View (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + } + + [Register ("android/view/View$OnClickListener", "", "Android.Views.IOnClickListenerInvoker")] + public interface IOnClickListener + { + [Register ("onClick", "(Landroid/view/View;)V", "GetOnClick_Landroid_view_View_Handler:Android.Views.IOnClickListenerInvoker")] + void OnClick (View v); + } + + [Register ("android/view/View$OnClickListener", DoNotGenerateAcw = true)] + internal sealed class IOnClickListenerInvoker : Java.Lang.Object, IOnClickListener + { + public IOnClickListenerInvoker (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + public void OnClick (View v) { } + } + + [Register ("android/view/View$OnLongClickListener", "", "Android.Views.IOnLongClickListenerInvoker")] + public interface IOnLongClickListener + { + [Register ("onLongClick", "(Landroid/view/View;)Z", "GetOnLongClick_Landroid_view_View_Handler:Android.Views.IOnLongClickListenerInvoker")] + bool OnLongClick (View v); + } +} + +namespace Android.Widget +{ + [Register ("android/widget/Button", DoNotGenerateAcw = true)] + public class Button : Android.Views.View + { + protected Button (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + } + + [Register ("android/widget/TextView", DoNotGenerateAcw = true)] + public class TextView : Android.Views.View + { + protected TextView (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + } +} + +namespace MyApp +{ + [Activity (MainLauncher = true, Label = "My App", Name = "my.app.MainActivity")] + public class MainActivity : Android.App.Activity + { + public MainActivity () { } + + [Register ("onCreate", "(Landroid/os/Bundle;)V", "GetOnCreate_Landroid_os_Bundle_Handler")] + protected override void OnCreate (object? savedInstanceState) => base.OnCreate (savedInstanceState); + } + + [Register ("my/app/MyHelper")] + public class MyHelper : Java.Lang.Object + { + [Register ("doSomething", "()V", "GetDoSomethingHandler")] + public virtual void DoSomething () { } + } + + [Service (Name = "my.app.MyService")] + public class MyService : Android.App.Service + { + protected MyService (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + } + + [BroadcastReceiver (Name = "my.app.MyReceiver")] + public class MyReceiver : Java.Lang.Object { } + + [ContentProvider (new [] { "my.app.provider" }, Name = "my.app.MyProvider")] + public class MyProvider : Java.Lang.Object { } + + [Register ("my/app/AbstractBase")] + public abstract class AbstractBase : Java.Lang.Object + { + protected AbstractBase (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + + [Register ("doWork", "()V", "")] + public abstract void DoWork (); + } + + [Register ("my/app/SimpleActivity")] + public class SimpleActivity : Android.App.Activity { } + + [Register ("my/app/ClickableView")] + public class ClickableView : Android.Views.View, Android.Views.IOnClickListener + { + protected ClickableView (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + + [Register ("onClick", "(Landroid/view/View;)V", "")] + public void OnClick (Android.Views.View v) { } + } + + [Register ("my/app/CustomView")] + public class CustomView : Android.Views.View + { + protected CustomView (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + + [Register ("", "()V", "")] + public CustomView () : base (default!, default) { } + + [Register ("", "(Landroid/content/Context;)V", "")] + public CustomView (Context context) : base (default!, default) { } + } + + [Register ("my/app/Outer")] + public class Outer : Java.Lang.Object + { + [Register ("my/app/Outer$Inner")] + public class Inner : Java.Lang.Object + { + protected Inner (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + } + } + + [Register ("my/app/ICallback", "", "MyApp.ICallbackInvoker")] + public interface ICallback + { + [Register ("my/app/ICallback$Result")] + public class Result : Java.Lang.Object + { + protected Result (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + } + } + + [Register ("my/app/TouchHandler")] + public class TouchHandler : Java.Lang.Object + { + [Register ("onTouch", "(Landroid/view/View;I)Z", "GetOnTouchHandler")] + public virtual bool OnTouch (Android.Views.View v, int action) => false; + + [Register ("onFocusChange", "(Landroid/view/View;Z)V", "GetOnFocusChangeHandler")] + public virtual void OnFocusChange (Android.Views.View v, bool hasFocus) { } + + [Register ("onScroll", "(IFJD)V", "GetOnScrollHandler")] + public virtual void OnScroll (int x, float y, long timestamp, double velocity) { } + + [Register ("getText", "()Ljava/lang/String;", "GetGetTextHandler")] + public virtual string? GetText () => null; + + [Register ("setItems", "([Ljava/lang/String;)V", "GetSetItemsHandler")] + public virtual void SetItems (string[]? items) { } + } + + [Register ("my/app/ExportExample")] + public class ExportExample : Java.Lang.Object + { + [Java.Interop.Export ("myExportedMethod")] + public void MyExportedMethod () { } + } + + [Application (Name = "my.app.MyApplication", BackupAgent = typeof (MyBackupAgent), ManageSpaceActivity = typeof (MyManageSpaceActivity))] + public class MyApplication : Java.Lang.Object { } + + [Instrumentation (Name = "my.app.MyInstrumentation")] + public class MyInstrumentation : Java.Lang.Object { } + + [Register ("my/app/MyBackupAgent")] + public class MyBackupAgent : Android.App.Backup.BackupAgent + { + protected MyBackupAgent (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + } + + [Register ("my/app/MyManageSpaceActivity")] + public class MyManageSpaceActivity : Android.App.Activity + { + protected MyManageSpaceActivity (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + } + + public class UnregisteredHelper : Java.Lang.Object { } + + [Register ("my/app/MyButton")] + public class MyButton : Android.Widget.Button + { + protected MyButton (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + } + + [Register ("my/app/MultiInterfaceView")] + public class MultiInterfaceView : Android.Views.View, Android.Views.IOnClickListener, Android.Views.IOnLongClickListener + { + protected MultiInterfaceView (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + + [Register ("onClick", "(Landroid/view/View;)V", "")] + public void OnClick (Android.Views.View v) { } + + [Register ("onLongClick", "(Landroid/view/View;)Z", "")] + public bool OnLongClick (Android.Views.View v) => false; + } + + [CustomJniName ("com.example.CustomWidget")] + public class CustomWidget : Java.Lang.Object { } + + [Activity (Name = "my.app.BaseActivityNoRegister")] + public class BaseActivityNoRegister : Android.App.Activity { } + + public class DerivedFromComponentBase : BaseActivityNoRegister { } + + [Register ("my/app/RegisteredParent")] + public class RegisteredParent : Java.Lang.Object + { + public class UnregisteredChild : Java.Lang.Object { } + } + + [Register ("my/app/DeepOuter")] + public class DeepOuter : Java.Lang.Object + { + public class Middle : Java.Lang.Object + { + public class DeepInner : Java.Lang.Object { } + } + } + + public class PlainActivitySubclass : Android.App.Activity { } + + [Activity (Label = "Unnamed")] + public class UnnamedActivity : Android.App.Activity { } + + public class UnregisteredClickListener : Java.Lang.Object, Android.Views.IOnClickListener + { + [Register ("onClick", "(Landroid/view/View;)V", "")] + public void OnClick (Android.Views.View v) { } + } + + public class UnregisteredExporter : Java.Lang.Object + { + [Java.Interop.Export ("doExportedWork")] + public void DoExportedWork () { } + } +} + +namespace MyApp.Generic +{ + [Register ("my/app/GenericHolder")] + public class GenericHolder : Java.Lang.Object where T : Java.Lang.Object + { + [Register ("getItem", "()Ljava/lang/Object;", "GetGetItemHandler")] + public virtual T? GetItem () => default; + } + + [Register ("my/app/GenericBase", DoNotGenerateAcw = true)] + public class GenericBase : Java.Lang.Object where T : class + { + protected GenericBase (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + } + + [Register ("my/app/ConcreteFromGeneric")] + public class ConcreteFromGeneric : GenericBase + { + protected ConcreteFromGeneric (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + } + + [Register ("my/app/IGenericCallback", "", "")] + public interface IGenericCallback { } + + [Register ("my/app/GenericCallbackImpl")] + public class GenericCallbackImpl : Java.Lang.Object, IGenericCallback + { + protected GenericCallbackImpl (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + } +} + +[Register ("my/app/GlobalType")] +public class GlobalType : Java.Lang.Object +{ + protected GlobalType (IntPtr handle, Android.Runtime.JniHandleOwnership transfer) : base (handle, transfer) { } +} + +public class GlobalUnregisteredType : Java.Lang.Object { } From ddd7023f197d2ff50cd20e2c346465998471826d Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 16 Feb 2026 16:42:50 +0100 Subject: [PATCH 2/5] [TrimmableTypeMap] Scanner behavior and contract tests Test marshal method collection, JNI signature decoding, activation constructor resolution, base type chain walking, interface resolution, compat JNI names, and component attribute metadata merging. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Scanner/JavaPeerScannerTests.Behavior.cs | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.Behavior.cs diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.Behavior.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.Behavior.cs new file mode 100644 index 00000000000..81aabe9ad74 --- /dev/null +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.Behavior.cs @@ -0,0 +1,133 @@ +using System.Linq; +using Xunit; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap.Tests; + +public partial class JavaPeerScannerTests +{ + [Theory] + [InlineData ("android/app/Activity", "OnCreate", "onCreate", "(Landroid/os/Bundle;)V")] + [InlineData ("android/app/Activity", "OnStart", "onStart", "()V")] + [InlineData ("my/app/MainActivity", "OnCreate", "onCreate", "(Landroid/os/Bundle;)V")] + [InlineData ("my/app/AbstractBase", "DoWork", "doWork", "()V")] + [InlineData ("java/lang/Throwable", "Message", "getMessage", "()Ljava/lang/String;")] + [InlineData ("my/app/TouchHandler", "OnTouch", "onTouch", "(Landroid/view/View;I)Z")] + [InlineData ("my/app/TouchHandler", "OnFocusChange", "onFocusChange", "(Landroid/view/View;Z)V")] + [InlineData ("my/app/TouchHandler", "OnScroll", "onScroll", "(IFJD)V")] + [InlineData ("my/app/TouchHandler", "SetItems", "setItems", "([Ljava/lang/String;)V")] + public void Scan_MarshalMethod_HasCorrectSignature (string javaName, string managedName, string jniName, string jniSig) + { + var peers = ScanFixtures (); + var method = FindByJavaName (peers, javaName) + .MarshalMethods.FirstOrDefault (m => m.ManagedMethodName == managedName || m.JniName == jniName); + Assert.NotNull (method); + Assert.Equal (jniName, method.JniName); + Assert.Equal (jniSig, method.JniSignature); + } + + [Fact] + public void Scan_MarshalMethod_ConstructorsAndSpecialCases () + { + var peers = ScanFixtures (); + + var ctors = FindByJavaName (peers, "my/app/CustomView") + .MarshalMethods.Where (m => m.IsConstructor).ToList (); + Assert.Equal (2, ctors.Count); + Assert.Equal ("()V", ctors [0].JniSignature); + Assert.Equal ("(Landroid/content/Context;)V", ctors [1].JniSignature); + + Assert.DoesNotContain (FindByJavaName (peers, "my/app/MyHelper").MarshalMethods, m => m.IsConstructor); + + var exportMethod = FindByJavaName (peers, "my/app/ExportExample").MarshalMethods.Single (); + Assert.Equal ("myExportedMethod", exportMethod.JniName); + Assert.Null (exportMethod.Connector); + + var onStart = FindByJavaName (peers, "android/app/Activity") + .MarshalMethods.FirstOrDefault (m => m.JniName == "onStart"); + Assert.NotNull (onStart); + Assert.Equal ("", onStart.Connector); + + var onClick = FindByManagedName (peers, "Android.Views.IOnClickListener") + .MarshalMethods.FirstOrDefault (m => m.JniName == "onClick"); + Assert.NotNull (onClick); + Assert.Equal ("(Landroid/view/View;)V", onClick.JniSignature); + + Assert.Equal ("Android.Views.IOnClickListenerInvoker", + FindByManagedName (peers, "Android.Views.IOnClickListener").InvokerTypeName); + } + + [Theory] + [InlineData ("android/app/Activity", "Android.App.Activity")] + [InlineData ("my/app/SimpleActivity", "Android.App.Activity")] + [InlineData ("my/app/MyButton", "MyApp.MyButton")] + public void Scan_ActivationCtor_InheritsFromNearestBase (string javaName, string expectedDeclaringType) + { + var peers = ScanFixtures (); + var peer = FindByJavaName (peers, javaName); + Assert.NotNull (peer.ActivationCtor); + Assert.Equal (expectedDeclaringType, peer.ActivationCtor.DeclaringTypeName); + } + + [Theory] + [InlineData ("java/lang/Object", null)] + [InlineData ("android/app/Activity", "java/lang/Object")] + [InlineData ("my/app/MainActivity", "android/app/Activity")] + [InlineData ("java/lang/Throwable", "java/lang/Object")] + [InlineData ("java/lang/Exception", "java/lang/Throwable")] + [InlineData ("my/app/MyButton", "android/widget/Button")] + public void Scan_BaseJavaName_ResolvesCorrectly (string javaName, string? expectedBase) + { + var peers = ScanFixtures (); + Assert.Equal (expectedBase, FindByJavaName (peers, javaName).BaseJavaName); + } + + [Fact] + public void Scan_MultipleInterfaces_AllResolved () + { + var peers = ScanFixtures (); + + var multi = FindByJavaName (peers, "my/app/MultiInterfaceView"); + Assert.Contains ("android/view/View$OnClickListener", multi.ImplementedInterfaceJavaNames); + Assert.Contains ("android/view/View$OnLongClickListener", multi.ImplementedInterfaceJavaNames); + Assert.Equal (2, multi.ImplementedInterfaceJavaNames.Count); + + Assert.Contains ("android/view/View$OnClickListener", + FindByJavaName (peers, "my/app/ClickableView").ImplementedInterfaceJavaNames); + Assert.Empty (FindByJavaName (peers, "my/app/MyHelper").ImplementedInterfaceJavaNames); + } + + [Theory] + [InlineData ("android/app/Activity", "android/app/Activity")] + [InlineData ("my/app/MainActivity", "my/app/MainActivity")] + public void Scan_CompatJniName (string javaName, string expectedCompat) + { + var peers = ScanFixtures (); + Assert.Equal (expectedCompat, FindByJavaName (peers, javaName).CompatJniName); + } + + [Fact] + public void Scan_CompatJniName_UnregisteredType_UsesRawNamespace () + { + var peers = ScanFixtures (); + var unregistered = FindByManagedName (peers, "MyApp.UnregisteredHelper"); + Assert.StartsWith ("crc64", unregistered.JavaName); + Assert.Equal ("myapp/UnregisteredHelper", unregistered.CompatJniName); + } + + [Fact] + public void Scan_CustomJniNameProviderAttribute_UsesNameFromAttribute () + { + var peers = ScanFixtures (); + Assert.Equal ("com/example/CustomWidget", + FindByManagedName (peers, "MyApp.CustomWidget").JavaName); + } + + [Theory] + [InlineData ("my/app/Outer$Inner", "MyApp.Outer+Inner")] + [InlineData ("my/app/ICallback$Result", "MyApp.ICallback+Result")] + public void Scan_NestedType_IsDiscovered (string javaName, string managedName) + { + var peers = ScanFixtures (); + Assert.Equal (managedName, FindByJavaName (peers, javaName).ManagedTypeName); + } +} From ab510e076160beb06f27bcc364b2fb5b69cf545b Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 16 Feb 2026 16:42:57 +0100 Subject: [PATCH 3/5] [TrimmableTypeMap] Scanner edge-case regression tests Cover generic base/interface type specification resolution, component- only base detection, unregistered nested type naming, deep nesting, empty namespace types, plain subclass CRC64 naming, and unregistered types with interfaces or [Export] methods. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Scanner/JavaPeerScannerTests.EdgeCases.cs | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.EdgeCases.cs diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.EdgeCases.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.EdgeCases.cs new file mode 100644 index 00000000000..ec368ed6a44 --- /dev/null +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.EdgeCases.cs @@ -0,0 +1,69 @@ +using System.Linq; +using Xunit; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap.Tests; + +public partial class JavaPeerScannerTests +{ + [Fact] + public void Scan_GenericTypes_ResolveViaTypeSpecification () + { + var peers = ScanFixtures (); + Assert.Equal ("my/app/GenericBase", + FindByJavaName (peers, "my/app/ConcreteFromGeneric").BaseJavaName); + Assert.Contains ("my/app/IGenericCallback", + FindByJavaName (peers, "my/app/GenericCallbackImpl").ImplementedInterfaceJavaNames); + } + + [Fact] + public void Scan_ComponentOnlyBase_BothBaseAndDerivedDiscovered () + { + var peers = ScanFixtures (); + + var baseType = FindByJavaName (peers, "my/app/BaseActivityNoRegister"); + Assert.True (baseType.IsUnconditional); + Assert.Equal ("android/app/Activity", baseType.BaseJavaName); + + var derived = FindByManagedName (peers, "MyApp.DerivedFromComponentBase"); + Assert.StartsWith ("crc64", derived.JavaName); + } + + [Theory] + [InlineData ("MyApp.RegisteredParent+UnregisteredChild", "my/app/RegisteredParent_UnregisteredChild")] + [InlineData ("MyApp.DeepOuter+Middle+DeepInner", "my/app/DeepOuter_Middle_DeepInner")] + public void Scan_UnregisteredNestedType_UsesParentJniPrefix (string managedName, string expectedJavaName) + { + var peers = ScanFixtures (); + Assert.Equal (expectedJavaName, FindByManagedName (peers, managedName).JavaName); + } + + [Fact] + public void Scan_EmptyNamespace_Handled () + { + var peers = ScanFixtures (); + Assert.Equal ("GlobalType", FindByJavaName (peers, "my/app/GlobalType").ManagedTypeName); + Assert.Equal ("GlobalUnregisteredType", + FindByManagedName (peers, "GlobalUnregisteredType").CompatJniName); + } + + [Theory] + [InlineData ("MyApp.PlainActivitySubclass")] + [InlineData ("MyApp.UnnamedActivity")] + [InlineData ("MyApp.UnregisteredClickListener")] + [InlineData ("MyApp.UnregisteredExporter")] + public void Scan_UnregisteredType_DiscoveredWithCrc64Name (string managedName) + { + var peers = ScanFixtures (); + Assert.StartsWith ("crc64", FindByManagedName (peers, managedName).JavaName); + } + + [Fact] + public void Scan_ExportOnUnregisteredType_MethodDiscovered () + { + var peers = ScanFixtures (); + var exportMethod = FindByManagedName (peers, "MyApp.UnregisteredExporter") + .MarshalMethods.FirstOrDefault (m => m.JniName == "doExportedWork"); + Assert.NotNull (exportMethod); + Assert.Null (exportMethod.Connector); + } +} From f1a2847806e143178caad26f0fd515582ee6d91f Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 16 Feb 2026 17:44:39 +0100 Subject: [PATCH 4/5] [TrimmableTypeMap] Wire unit tests into solution and CI Add TrimmableTypeMap, TrimmableTypeMap.Tests, and TestFixtures projects to Xamarin.Android.sln. Add CI step to run scanner unit tests and publish results on the Windows build pipeline. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Xamarin.Android.sln | 20 +++++++++++++++++++ .../yaml-templates/build-windows-steps.yaml | 15 ++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/Xamarin.Android.sln b/Xamarin.Android.sln index d1edbe95c97..d5554ea849a 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.Tests", "tests\Microsoft.Android.Sdk.TrimmableTypeMap.Tests\Microsoft.Android.Sdk.TrimmableTypeMap.Tests.csproj", "{F9CD012E-67AC-4A4E-B2A7-252387F91256}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestFixtures", "tests\Microsoft.Android.Sdk.TrimmableTypeMap.Tests\TestFixtures\TestFixtures.csproj", "{C5A44686-3469-45A7-B6AB-2798BA0625BC}" +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 + {F9CD012E-67AC-4A4E-B2A7-252387F91256}.Debug|AnyCPU.ActiveCfg = Debug|Any CPU + {F9CD012E-67AC-4A4E-B2A7-252387F91256}.Debug|AnyCPU.Build.0 = Debug|Any CPU + {F9CD012E-67AC-4A4E-B2A7-252387F91256}.Release|AnyCPU.ActiveCfg = Release|Any CPU + {F9CD012E-67AC-4A4E-B2A7-252387F91256}.Release|AnyCPU.Build.0 = Release|Any CPU + {C5A44686-3469-45A7-B6AB-2798BA0625BC}.Debug|AnyCPU.ActiveCfg = Debug|Any CPU + {C5A44686-3469-45A7-B6AB-2798BA0625BC}.Debug|AnyCPU.Build.0 = Debug|Any CPU + {C5A44686-3469-45A7-B6AB-2798BA0625BC}.Release|AnyCPU.ActiveCfg = Release|Any CPU + {C5A44686-3469-45A7-B6AB-2798BA0625BC}.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} + {F9CD012E-67AC-4A4E-B2A7-252387F91256} = {CAB438D8-B0F5-4AF0-BEBD-9E2ADBD7B483} + {C5A44686-3469-45A7-B6AB-2798BA0625BC} = {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..48544d84d7a 100644 --- a/build-tools/automation/yaml-templates/build-windows-steps.yaml +++ b/build-tools/automation/yaml-templates/build-windows-steps.yaml @@ -77,6 +77,21 @@ 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 + - task: BatchScript@1 displayName: Test dotnet-local.cmd - create template inputs: From 3a0b53b4d5155ce4caaaaaec172eca095a738d0e Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 19 Feb 2026 13:18:52 +0100 Subject: [PATCH 5/5] Fix: detect custom IJniNameProviderAttribute implementations The scanner only recognized RegisterAttribute and known component attributes (Activity, Service, etc.) as JNI name providers. Custom attributes implementing IJniNameProviderAttribute were ignored, causing affected types to fall back to CRC64-based JNI names. Add ImplementsJniNameProviderAttribute() that checks the attribute type's interface implementations via metadata, handling both TypeReference (cross-assembly) and TypeDefinition (same-assembly) interface handles. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Scanner/AssemblyIndex.cs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/AssemblyIndex.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/AssemblyIndex.cs index cf8bd977a71..4da7b3d752d 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/AssemblyIndex.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/AssemblyIndex.cs @@ -105,6 +105,13 @@ void Build () applicationAttributeInfo.BackupAgent = TryGetTypeProperty (ca, "BackupAgent"); applicationAttributeInfo.ManageSpaceActivity = TryGetTypeProperty (ca, "ManageSpaceActivity"); } + } else if (attrInfo is null && ImplementsJniNameProviderAttribute (ca)) { + // Custom attribute implementing IJniNameProviderAttribute (e.g., user-defined [CustomJniName]) + var name = TryGetNameProperty (ca); + if (name is not null) { + attrInfo = new TypeAttributeInfo (attrName); + attrInfo.JniName = name.Replace ('.', '/'); + } } } @@ -129,6 +136,36 @@ static TypeAttributeInfo CreateTypeAttributeInfo (string attrName) static bool IsKnownComponentAttribute (string attrName) => KnownComponentAttributes.Contains (attrName); + /// + /// Checks whether a custom attribute's type implements Java.Interop.IJniNameProviderAttribute. + /// Only works for attributes defined in the assembly being scanned (MethodDefinition constructors). + /// + bool ImplementsJniNameProviderAttribute (CustomAttribute ca) + { + if (ca.Constructor.Kind != HandleKind.MethodDefinition) { + return false; + } + var methodDef = Reader.GetMethodDefinition ((MethodDefinitionHandle)ca.Constructor); + var typeDef = Reader.GetTypeDefinition (methodDef.GetDeclaringType ()); + foreach (var implHandle in typeDef.GetInterfaceImplementations ()) { + var impl = Reader.GetInterfaceImplementation (implHandle); + if (impl.Interface.Kind == HandleKind.TypeReference) { + var typeRef = Reader.GetTypeReference ((TypeReferenceHandle)impl.Interface); + if (Reader.GetString (typeRef.Name) == "IJniNameProviderAttribute" && + Reader.GetString (typeRef.Namespace) == "Java.Interop") { + return true; + } + } else if (impl.Interface.Kind == HandleKind.TypeDefinition) { + var ifaceDef = Reader.GetTypeDefinition ((TypeDefinitionHandle)impl.Interface); + if (Reader.GetString (ifaceDef.Name) == "IJniNameProviderAttribute" && + Reader.GetString (ifaceDef.Namespace) == "Java.Interop") { + return true; + } + } + } + return false; + } + internal static string? GetCustomAttributeName (CustomAttribute ca, MetadataReader reader) { if (ca.Constructor.Kind == HandleKind.MemberReference) {