Skip to content

Conversation

@simonrozsival
Copy link
Member

@simonrozsival simonrozsival commented Feb 3, 2026

This specification defines the architecture for enabling Java-to-.NET interoperability in .NET Android applications using the .NET Type Mapping API. The design is fully compatible with Native AOT and trimming.

  • AOT-Safe: All type instantiation and method resolution works with Native AOT
  • Trimming-Safe: Proper annotations ensure required types survive aggressive trimming
  • Developer Experience: No changes required to existing .NET Android application code

Expands on dotnet/runtime#120121

Proof of concept

See https://github.com/dotnet/android/compare/dev/simonrozsival/trimmable-typemap

Core idea

Each Java peer type is registered using assembly-level attributes:

// TypeMap<TUniverse>(string jniClassName, Type proxyType, Type trimTarget)
// - jniClassName: Java class name used as lookup key
// - proxyType: The proxy type RETURNED by TypeMap lookups
// - trimTarget: Ensures trimmer preserves mapping when target is used

[assembly: TypeMap<Java.Lang.Object>("com/example/MainActivity", typeof(MainActivity_Proxy))] // no trim target -> unconditionally preserved
[assembly: TypeMap<Java.Lang.Object>("com/example/MainActivity", typeof(MainActivity_Proxy), typeof(MainActivity))] // trim target -> type map record will be trimmed if trim target is trimmed

The *_Proxy types are generated attribute classes which is applied to itself:

// Proxy applies ITSELF as an attribute to ITSELF
[MainActivity_Proxy]  // Self-application
public sealed class MainActivity_Proxy : JavaPeerProxy, IAndroidCallableWrapper
{
    public override IJavaPeerable CreateInstance(IntPtr handle, JniHandleOwnership transfer)
        => new MainActivity(handle, transfer);
    
    public override JavaPeerContainerFactory GetContainerFactory()
        => JavaPeerContainerFactory.Create<MainActivity>();
    
    // IAndroidCallableWrapper - only on ACW types
    public IntPtr GetFunctionPointer(int methodIndex) => methodIndex switch {
        0 => (IntPtr)(delegate* unmanaged<...>)&n_OnCreate,
        _ => IntPtr.Zero
    };
    
    // marshal methods...
    [UnmanagedCallersOnly]
    public static void n_OnCreate(...) { ... }
}

// At runtime:
Type proxyType = typeMap["com/example/MainActivity"];  // Returns typeof(MainActivity_Proxy)
JavaPeerProxy proxy = proxyType.GetCustomAttribute<JavaPeerProxy>();  // Returns MainActivity_Proxy instance
IJavaPeerable instance = proxy.CreateInstance(handle, transfer);  // Returns MainActivity instance

Why this works:

  1. TypeMap returns the proxy type, not the target type
  2. The .NET runtime's GetCustomAttribute<T>() instantiates attributes in an trimming and AOT-safe manner
  3. The trimTarget parameter ensures the mapping is preserved when the target type survives trimming

/cc @jtschuster


Related


- Debug and Release builds using CoreCLR or NativeAOT runtime
- All Java peer types: user classes, SDK bindings, interfaces with invokers
- `[Register]` and `[Export]` attribute methods
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that we are already considering [Export] not trimmer safe at all -- it currently emits trimmer warnings.

It isn't a required feature for Android, as you can just make an interface instead, and the entire API will be strongly typed. [Export] is basically C# -> strings -> generate a Java class/method with your string.

An example of removing [Export]:

So, we could consider improving [Export] future, out of scope.

@rolfbjarne
Copy link
Member

I think this looks great, and I don't see any major problems for macios either!

Comment on lines 1043 to 1044
**Key Insight:** In the legacy system, most types are only preserved if they're referenced by user code. The legacy `MarkJavaObjects` only unconditionally marks:
1. Types with `[Activity]`, `[Service]`, `[BroadcastReceiver]`, `[ContentProvider]`, `[Application]`, `[Instrumentation]` attributes
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess you could technically, not use these attributes and put them in your AndroidManifest.xml file manually.

That means that AndroidManifest.xml can define additional "roots" other than the application assembly. I don't think people would commonly do this, but maybe we should mention it's not trimmer safe -- they'd need to preserve the type another way.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already scan layouts and root the types that appear in those XML files. We can easily scan AndroidManifest.xml as well.

- Rename JavaPeerContainerFactory → DerivedTypeFactory to match implementation
- Rename GetContainerFactory() → GetDerivedTypeFactory()
- Add Integration with JavaConvert section explaining how IList<T>,
  IDictionary<K,V>, and ICollection<T> use DerivedTypeFactory via TypeMap
- Add Primitive/String Element Types section explaining direct converters
- Update section cross-references
- Updated API to reflect simplified methods (removed unused Set/empty creators)
- Removed FromHandle suffix from method names
- Added 'PoC Usage' sections showing exactly where the factory is used:
  - TypeMapAttributeTypeMap.CreateArray for array marshalling
  - JavaConvert for IList<T>, ICollection<T>, IDictionary<K,V> marshalling
- Explained why primitives need explicit converters (no proxies in TypeMap)
- Updated supported container types table
Document the gap where manually-added manifest entries (without C# attributes)
may be trimmed. Propose scanning merged AndroidManifest.xml for component
references (activity, service, receiver, provider, backupAgent) similar to
how layout XML is scanned for custom views.

This addresses @jonathanpeppers' review comment about manual manifest entries.
Covers key Native AOT considerations:
- Why Native AOT differs from ILLink+MonoVM (ILC whole-program analysis)
- Forbidden patterns (MakeGenericType, Activator.CreateInstance, Array.CreateInstance)
- JNI callback implementation with UnmanagedCallersOnly
- Symbol export requirements for JNI methods
- Crypto/TLS integration (whole-archive, JNI init, ProGuard rules)
- TypeMap runtime initialization timing
- Build pipeline differences
- Debugging common failure patterns
- Future considerations (.NET 10+ TypeMapping API)

Focuses on reasoning and 'why' rather than implementation details.
| Component | Location | Responsibility |
|-----------|----------|----------------|
| `GenerateTypeMaps` | MSBuild task | Scan assemblies, generate TypeMapAssembly.dll, .java, .ll |
| `TypeMapAssembly.dll` | Generated | Contains proxies, UCOs, TypeMap attributes |
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, one question about this assembly. If we kept 100% compatibility with existing code, then it would need to:

  • Potentially access internal types in other assemblies
  • Access nested private/internal types

Today someone can make nested/private/internal types that extend Java.Lang.Object and they work.

Is there some discussion in here how to solve this problem?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Elaborating on this, today there are nested protected internal types within Mono.Android.dll which can be subclassed, e.g. Android.Content.AsyncQueryHandler.WorkerArgs.

It is also not required for new Java.Lang.Object subclasses to be public; internal is perfectly valid today:

internal class Whatever : Java.Lang.Object {
}

A possible solution would be a trimmer step which just makes all types public.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We only need to generate [assembly: IgnoresAccessChecksTo("...")] in the typemap assembly for each assembly it references and we can call private methods on private classes. That alone fixes the problem. I verified this in PoC. I was also testing [UnsafeAccessors(...)] and it would be also a working solution to this problem.

@agocke
Copy link
Member

agocke commented Feb 5, 2026

I don’t think we need the llvm il stuff or anything mono aot related — we aren’t changing the old system so I think it should work fine for older tfms without modification.

│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────────┐│
│ │ ILLink/Trimmer │ │ Java Compiler │ │ LLVM → .o → .so ││
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For Native AOT, LLVM output should compile to a .a which is later used as input by the Native AOT toolchain.


### 3.1 High-Level Design

```
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Markdown is a superset of HTML. Why not just use HTML tables?

│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────────┐│
│ │ ILLink/Trimmer │ │ Java Compiler │ │ LLVM → .o → .so ││
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For Native AOT, LLVM output should compile to a .a which is later used as input by the Native AOT toolchain. I'm not sure where

┌─────────────────────────────────────────────────────────────────────────────┐
│ RUNTIME │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need a "step 0" and elaboration of setup for Java > Native invocations.

Background: dotnet/java-interop@356485e

The Java Native Interface allows native code to be associated
with a Java native method declaration, either by way of
Java_-prefixed native functions, or via function pointers
provided to JNIEnv::RegisterNatives().

The fact that you're mentioning LLVM → .o → .so strongly suggests that you're planning on requiring Java_-prefixed native functions.

Firstly, the fact that there is a choice and that you're making it should be explicitly mentioned somewhere.

Secondly, in order for Java_-prefixed symbols to work, something needs to load the .so before the Java native method is invoked.

(In the case of NativeAOT, if the symbols are instead part of a .a which is linked into libApp.so, then the .so does not need to be explicitly loaded, as libApp.so is already loaded as part of bootstrap.)

I see no mention of System.loadLibrary() within the current document. Presumably mono/android/MonoPackageManager.java will need to be updated to load this lib.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing this out. I want to use the Java_-prefixed symbols. I will update the spec to clearly state that we need to call System.loadLibrary().

bool TryGetTypesForJniName(string jniSimpleReference, [NotNullWhen(true)] out IEnumerable<Type>? types);

// .NET-to-Java type resolution
bool TryGetJniNameForType(Type type, [NotNullWhen(true)] out string? jniName);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API design curiosity: why an out string? parameter instead of a string? return type? I haven't looked to see if the .NET Framework Design Guidelines have been updated for nullability in a way that addresses this question.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API design naming: if both (Try)GetJniNameForType(Type [, out string?]) and GetJniNamesForType() are kept, it may be useful to insert an adjective on the "singular' overload, e.g. GetDefaultJniNameForType().

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both designs would work just fine. I prefer the Try variant because it's closer to how Dictionary lookups are used. It's just more explicit. This API is not meant for public consumption and it should be an internal interface, I should mention that as well.


// .NET-to-Java type resolution
bool TryGetJniNameForType(Type type, [NotNullWhen(true)] out string? jniName);
IEnumerable<string> GetJniNamesForType(Type type);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API design question: when would you call this instead of TryGetJniNameForType()? Looking at https://github.com/dotnet/android/compare/dev/simonrozsival/trimmable-typemap , the apparent answer is when implementing GetSimpleReferences(), but as all GetJniNamesForType() return a single-element array, I'm not sure how this differs.

The "real" answer is because of JniRuntime.JniTypeManager.GetSimpleReferences(), but I can no longer remember why this needs to provide a 1:many for System.Type : JNI values. ("Aliases" are the other direction, for JNI : Type mappings, as multiple types may bind e.g. java.lang.Object.)

It may be "interesting" to just forego this method entirely and see what breaks.

Copy link
Member Author

@simonrozsival simonrozsival Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is only because of JniRuntime.JniTypeManager.GetSimpleReferences(). One option would be to deprecate that public method and skip the implementation for this proposed typemap.

Could this be needed for "MAM" type replacement?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
IEnumerable<string> GetJniNamesForType(Type type);

IJavaPeerable? CreatePeer(IntPtr handle, JniHandleOwnership transfer, Type? targetType);

// Marshal method function pointer resolution
IntPtr GetFunctionPointer(ReadOnlySpan<char> className, int methodIndex);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming + clarification: what is className? The Java class name or the managed (C#) class name? The name should clearly tell us which is needed.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
IntPtr GetFunctionPointer(ReadOnlySpan<char> className, int methodIndex);
IntPtr GetFunctionPointer(ReadOnlySpan<char> jniName, int methodIndex);

else if (RuntimeFeature.IsMonoRuntime)
return new MonoTypeMap();
else
throw new NotSupportedException();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems somewhat odd that Native AOT isn't mentioned here, while it is mentioned elsewhere…

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an oversight. It should be if (RuntimeFeature.IsCoreClrRuntime || RuntimeFeature.IsNativeAotRuntime)


| Type Category | Example | JCW? | TypeMap Entry | GetFunctionPointer | CreateInstance |
|---------------|---------|------|---------------|-------------------|----------------|
| User class with JCW | `MainActivity` | Yes || Returns UCO ptrs | `new T(h, t)` |
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would new T(h, t) work? Current convention is that most user-written classes do not have the (IntPtr, JniHandleOwnership) "activation constructor", e.g.

public class MainActivity : Activity

[Activity()]
public partial class MainActivity {
  // No explicit constructor at all! which means it only has a compiler-provided default constructor.
}

I imagine this can work by updating the IL to add such a constructor… But such IL rewriting tends to be a source of pain and suffering in the context of incremental builds.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, since this is CreateInstance() on the proxy type, this doesn't need to be new T(h, t), but it can instead be RuntimeHelpers.GetUninitializedObject) + .SetPeerReference(h) + ConstructorInfo.Invoke(instance). Though you'd have to somehow obtain a ConstructorInfo in a NativeAOT environment…

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, this is an oversimplification and I believe this is described later on, I need to doublecheck. The generated code will need to match the current reflection-based implementation, which uses GetUninitializedObject() + direct call to the (protected) base class .ctor(h, t). This can be achieved easily in IL + [assembly: IgnoresAccessChecksTo("...")]

|---------------|---------|--------|
| Invoker | `IOnClickListenerInvoker` | Share JNI name with interface; instantiated by interface proxy |
| `DoNotGenerateAcw` types without activation | Internal helpers | No JCW, no peer creation from Java |
| Generic types | `List<T>` | Not directly mapped to Java |
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This requires elaboration. Generic types can be exposed to Java!

class GenericHolder<T> : Java.Lang.Object {
public T Value {get; set;}
}

CreateInstance() cannot be implemented in this scenario, and already throws an exception should you try to do so, but other ITypeMap methods can be implemented for GenericHolder<T>.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing this out. Yes, there's no way to implement CreateInstance, so we won't. The object needs to be created on the .NET side and as you said, we can later obtain a reference to it through GetObject<GenericHolder<T>>(ptr). I will update the spec to explicitly mention that generic subclasses are supported in this way.


---

## 5. Type Map Attributes
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this section come after §6 JavaPeerProxy Design so that IAndroidCallableWrapper "exists" by the time we see it used in MainActivity_Proxy?

[assembly: TypeMap<Java.Lang.Object>("com/example/MainActivity", typeof(MainActivity_Proxy), typeof(MainActivity))]
```

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"obvious questions are obvious":

What emits this attribute, where is it located? Is it part of TypeMapAssembly.dll? It it (ever) hand-written or part of generator output?

(Asked earlier) How does this handle non-public types?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will update the document to make it obvious that these are generated alongside teh Proxy types in the pre-trimming codegen step and are part of the "TypeMapAssembly.dll". It will never be hand-written.

This will be generated directly in IL, so we can emit typerefs even to private types. As I've written in another comment, we also need to rely on [assembly: IgnoresAccessChecksTo("...")]

public sealed class MainActivity_Proxy : JavaPeerProxy, IAndroidCallableWrapper
{
public override IJavaPeerable CreateInstance(IntPtr handle, JniHandleOwnership transfer)
=> new MainActivity(handle, transfer);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned above, there likely is not a MainActivity(IntPtr, JniHandleOwnership) constructor. How does this work?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Answered in another thread


```csharp
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, Inherited = false)]
abstract class JavaPeerProxy : Attribute
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This confuses me, actually: .NET Framework Design Guidelines is that Attribute subclasses should have an Attribute suffix. Yet here we don't.

Additionally, the lack of an Attribute suffix makes me think I've been mis-reading JavaPeerProxy usage before I made this realization…

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. The JavaPeerProxy class should probably have the Attribute suffix, since we need to refer to this class in the codebase and I understand your confusion. For the generated subclasses, the name doesn't really matter and they are never referenced by name in code.

I don't think about this class as an attribute most time. It's basically a hack which allows to safely create instance of this class when we only have a Type reference. I suppose we might just as well add [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] on JavaPeerProxy and use Activator.CreateInstance on it. The end result will be mostly the same.

// At runtime:
Type proxyType = typeMap["com/example/MainActivity"]; // Returns typeof(MainActivity_Proxy)
JavaPeerProxy proxy = proxyType.GetCustomAttribute<JavaPeerProxy>(); // Returns MainActivity_Proxy instance
IJavaPeerable instance = proxy.CreateInstance(handle, transfer); // Returns MainActivity instance
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What this "at runtime" example doesn't describe is how "activation" is supposed to work, mentioned in: dotnet/java-interop@d3d3a1b

(Alas, terminology is inconsistent; we have an (IntPtr, JniHandleOwnership) "activation constructor", which is not the TypeManager.n_Activate() codepath:

static void n_Activate (IntPtr jnienv, IntPtr jclass, IntPtr typename_ptr, IntPtr signature_ptr, IntPtr jobject, IntPtr parameters_ptr)
)

Because you love MainActivity… 😉

Given:

namespace Example;

[Activity(…)]
public partial class MainActivity : Activity {
    public MainActivity() => Console.WriteLine("MainActivity constructed!");
    // Note: no (IntPtr, JniHandleOwnership) "activation" constructor present!
}

The Java Callable Wrapper resembles:

package example;

public class MainActivity extends android.app.Activity implements mono.android.IGCUserPeer
{
/** @hide */
	public static final String __md_methods;
	static {
		__md_methods = "…";
		mono.android.Runtime.register ("android_v9_intune.MainActivity, android-v9-intune", MainActivity.class, __md_methods);
	}

	public MainActivity ()
	{
		super ();
		if (getClass () == MainActivity.class) {
			mono.android.TypeManager.Activate ("android_v9_intune.MainActivity, android-v9-intune", "", this, new java.lang.Object[] {  });
		}
	}

	// …
}

What happens when a user taps the app icon on their home screen?

  1. Android "does stuff" to map the icon to an app + Java class name.

  2. Once it has a java.lang.Class for (1), it calls Class.newInstance()

  3. (2) is equivalent to new example.MainActivity()

  4. The example.MainActivity default constructor executes. Note: at this time, (1)…(4) are all in Java-land. No .NET code has executed (except the static initializer and Runtime.register(), if it hasn't already executed.)

  5. The MainActivity default constructor calls TypeManager.Activate(…, this, …).

  6. Now we enter managed-land with TypeManager.Activate(). which has these implied semantics:

    1. An instance of the C# Example.MainActivity type is created.

    2. The .Handle value from (a) is the "same" JNI handle provided to TypeManager.Activate().

      (i) and (ii) could plausibly be done via proxy.CreateInstance(handle, transfer), but then!

    3. The corresponding C# constructor is then invoked on the instance from (i)

Meaning that by the end of 6.iii adb logcat had better contain MainActivity constructed!, or things are broken.

How do you implement 6.iii?

Additionally, this happens for all Java-side construction. We are not restricted to just default constructors. (See also CallVirtualFromConstructorBase.cs and CallVirtualFromConstructorDerived.cs.)

Additionally, constructors can be overloaded; there need not be just one.

A spitballed solution is for CreateInstance() to instead be:

IJavaPeerable? CreateInstance(IntPtr handle, JniHandleOwnership transfer, ReadOnlySpan<char> jniMethodSignature, JniObjectArray? arguments);

Generated IL could then be of the form:

public IJavaPeerable? CreateInstance(IntPtr handle, JniHandleOwnership transfer, ReadOnlySpan<char> jniMethodSignature, JniObjectArray? arguments)
{
    if (!jniMethodSignature.Equals("()V")) return null;
    var value = (IJavaPeerable) RuntimeHelpers.GetUninitializedObject(typeof(MainActivity));
    value.SetPeerReference(new PeerReference(handle));
    /* the IL form of */ MainActivity::.ctor(value);
    return value;
}

At a high level, this feels viable. Implementation wise, you're gonna need to deal with type coercion, e,g. if the C# constructor is MyType(int value), then arguments[0] would contain a java.lang.Integer value which needs to be converted to a System.Int32.

This can be done. But this vastly increases the complexity of your CreateInstance() method pattern.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upon further reading (the joys of commenting while reading…), the MainActivity_Proxy.nc_activate_0() method also fulfills the "Java-side activation" scenario.

However, it wasn't explicitly called out! 😉

Additionally, it's presence implies changes to Java Callable Wrappers:

package example;

public class MainActivity extends android.app.Activity implements mono.android.IGCUserPeer
{
/** @hide */
	public static final String __md_methods;
	static {
		__md_methods = "…";
		mono.android.Runtime.register ("android_v9_intune.MainActivity, android-v9-intune", MainActivity.class, __md_methods);
	}

	public MainActivity ()
	{
		super ();
		if (getClass () == MainActivity.class) {
			nc_activate();
		}
	}
    native void nc_activate();

	public MainActivity (int value) // overload for exposition
	{
		super ();
		if (getClass () == MainActivity.class) {
			nc_activate(value);
		}
	}
    native void nc_activate(int value);
	// …
}

Which should work, and makes the "argument marshaling" scenario easier (everything isn't boxed into a Java-side Object[]!), but does mean that JCWs contents now vary based on selected runtime. This would also need to be called out.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You got it right in the second comment, I think. Let me break it down:

  1. Yes, I would like to make registered (exported?) ctors callable via the same mechanism as any other reverse p/invoke and avoid the reflection codepath in TypeManager.Activate.
  2. So there are 2 types of constructors there's the "XI" (IntPtr, JniTransferOptions) and "JI" (ref JniPeerReference, ???) that should be invoked from Object.GetObject(javaThis) - that's the JavaPeerProxy.CreateInstance codepaths. And the second group are the other "registered" ("exported"?) constructors that are called via TypeManager.Activate and those I call the "activation" constructors. Maybe I should change the name.
  3. Yes, JCWs already differ significantly based on $(_TypeMapKind): "mvid" (for marshal methods) and "strings-asm" (for runtime native members registartion using reflection and dynamic codegen). Currently, I don't expect the JCW to contain any static constructor and instead lazily get function pointers similarly to marshal methods, but that could change if we go in this direction instead: [Draft][Proposal] Trimmable Type Map #10757 (comment)


### 9.3 IgnoresAccessChecksTo for Protected Constructors

When the activation constructor is protected or in a base class, the TypeMaps assembly uses `IgnoresAccessChecksToAttribute` to bypass access checks:
Copy link
Contributor

@jonpryor jonpryor Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When did tihs type appear?! (Actually, I can't find this type. Does it exist yet?)

This could possibly address the questions and concerns around referencing non-public types!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a secret feature of the .NET runtime 😄 We need to declare the attribute ourselves, it's not part of the runtime libraries. Some (unofficial) explanation/example is for example here: https://www.strathweb.com/2018/10/no-internalvisibleto-no-problem-bypassing-c-visibility-rules-with-roslyn/


---

## 10. Java Constructor Generation
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest renaming to Java Callable Wrapper Constructor Generation

[UnmanagedCallersOnly]
public static void n_{MethodName}_mm_{Index}(IntPtr jnienv, IntPtr obj, ...)
{
AndroidRuntimeInternal.WaitForBridgeProcessing();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should follow the pattern from dotnet/java-interop@356485e

[UnmanagedCallersOnly]
public static void n_{MethodName}_mm_{Index}(IntPtr jnienv, IntPtr obj, ...)
{
  if (!JniEnvironment.BeginMarshalMethod (jnienv, out var __envp, out var __r))
    return;
  try {
    TargetType.n_{MethodName}_{JniSignature}(jnienv, obj, ...);
  } catch (Exception __e) {
    __r?.OnUserUnhandledException (ref __envp, __e);
  } finally {
    JniEnvironment.EndMarshalMethod (ref __envp);
  }
}

as this pattern behaves properly when a Debugger is attached.

Additionally, if we can detect that TargetType.n_{MethodName}_{JniSignature} was emitted by a recent generator (TargetFramework .NET 10+), then no wrapper is needed; it can be called directly.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing this out.

@class_name = internal constant [...] c"c\00o\00m\00/\00...\00[\001\00]\00"
```

The bracket characters `[` and `]` cannot appear in valid JNI names, guaranteeing no collisions.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

. is also not valid in JNI names, so if you want a shorter form, you could use .1 instead of [1].

├──────────────────────────────────────────────┐
▼ ▼
3. LLVM Compilation 4. Java Compilation
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunate aspect of this is that LLVM generation, compilation, and linking now becomes part of the Debug build process. This will almost certainly slow things down.

Are we sure we want to do that?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, this will definitely make the first Debug build slower. If we get caching right, it shouldn't have much impact on subsequent builds, until a new type is added to the typemap and the build caches are invalidated. If we went with #10757 (comment), we wouldn't need any LLVM code and we would only be dealing with the .java->.class->.dex steps which we already do in the current Debug builds.

├────────────────────┬───────────────────────────┤ │
▼ ▼ ▼ │
6. LLVM Compilation 7. Native Linking 8. R8 (Java shrink) │
- .ll → .o - Link ONLY surviving - Uses ProGuard │
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm concerned about (8). IIRC we generate a ProGuard file to preserve generated Java Callable Wrappers, which means they'll be out of sync with a post-trimmed world.

Maybe we can emit a ProGuard file as part of (5) to remove the trimmed Java types?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see that this is mentioned in §15.5 (3).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The order in which things are introduced in this document obviously needs improving 😄

Post-Trimming Filter
├─► Surviving .o files → link into libmarshal_methods.so
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Native AOT builds, libmarshal_methods.so shouldn't exist; the contents should be merged into "libApp.so".

}

public static void n_OnCreate(IntPtr jnienv, IntPtr native__this, IntPtr bundle) {
var __this = (MainActivity)Java.Lang.Object.GetObject<MainActivity>(native__this);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this an "inlined" example, or how you actually want things to work.

I think this should actually be:

public static void n_OnCreate(IntPtr jnienv, IntPtr native__this, IntPtr bundle)
  => Activity.n_OnCreate_Landroid_os_Bundle_Landroid_os_PersistableBundle_(jnienv, native__this, bundle);

If it's not an inlined example, then the codegen pattern needs to follow that of dotnet/java-interop@356485e so that exceptions are properly marshaled.

The one scenario where the *_Proxy methods should contain the "real" method invocation is for [Export] methods, as there is no generated marshal method to reference.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a simplified ("inlined") example. I will consider replacing it with ... to make it more obvious


**Why this works:**
1. unconditional TypeMapAttribute **unconditionally preserves** MainActivity_Proxy
2. MainActivity_Proxy has **direct method references** to MainActivity
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm somewhat suspicious of this, because it means we can't "delegate" to the generator-emitted marshal method. This will result in code bloat, as (otherwise identical) methods can't be shared across subclasses.

Copy link
Member Author

@simonrozsival simonrozsival Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will result in code bloat, as (otherwise identical) methods can't be shared across subclasses

Let me try to show this on an example to see if I understand your point:

class A : Activity { public override void OnCreate(...) { ... } }
class B : A { public override void OnCreate(...) { ... } }

Now the generated code of the ACW proxy types will look something like this:

class A_Proxy {
    [UnmanagedCallersOnly]
    public static void n_OnCreate_mm(...) {
        var a = Object.GetObject<A>(thisHandle);
        a.n_OnCreate(...);
    }
}

class B_Proxy {
    [UnmanagedCallersOnly]
    public static void n_OnCreate_mm(...) {
        var b = Object.GetObject<B>(thisHandle);
        b.n_OnCreate(...);
    }
}

So you're saying that instead of generating B.n_OnCreate_mm, the Java native method could point directly to A.n_OnCreate_mm and save the basically duplicate IL and the .ll trampoline for this virtual method? That's definitely something we should do. Since B : A, we can expect A_Proxy to exist when B exists.

Or did you have something else in mind?

| Detection Criteria | Preservation | Reason |
|-------------------|--------------|--------|
| User type with `[Activity]`, `[Service]`, etc. attribute | **Unconditional** | Android creates these |
| User type subclassing Android component (Activity, etc.) | **Unconditional** | Android creates these |
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm uncertain what is meant by this. If I have a "random" Activity subclass without [Activity]:

partial class MyOtherwiseUnreferencedActivity : Activity {
}

then I should want that type to be trimmed away. Android cannot access that Activity unless it's in AndroidManifest.xml.

…though as a complication, AndroidManifest.xml can contain user-written XML fragments, so it is possible to have an <activity/> element that references MyOtherwiseUnreferencedActivity without use of [Register] or [Activity]. This can be determined by reading AndroidManifest.xml.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section is maybe overly ambitious at writing exactly what needs to be preserved 😄 The source for this information is the existing implementation in the "java stub generator" and "jcw generator" and friends and also the MarkJavaObjects + other custom trimmer steps.

I will revisit this section and I'll adapt it based on your input. The existing implementation already scans layout xml files and mark types manually. I need to doublecheck if we scan AndroidManifest.xml or not. Apparently we should. The unconditional preservation of activities is based on the assumption "if you declare an activity in your app, you probably also want to use it, otherwise you would delete it, right?". we can do better.

| User type with `[Activity]`, `[Service]`, etc. attribute | **Unconditional** | Android creates these |
| User type subclassing Android component (Activity, etc.) | **Unconditional** | Android creates these |
| Custom view referenced in layout XML | **Unconditional** | Android inflates these |
| Interface with `[Register]` | **Trimmable** | Only if .NET implements/uses |
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please clarify "Reason" entry. Is the interface trimmable if .NET implements it? (Which sounds backwards?) Or is the interface preserved if a .NET type implements it?

Closely related question: what about interface members. IIRC the trimmer could remove methods/properties/etc. that it determined weren't used by the app. In the case of bound Java interfaces, that would be Bad™.

```

**LLVM IR Generation:** For Native AOT, we generate LLVM IR files (`.ll`) that define the JNI entry points. These are compiled alongside the ILC output and linked into the final shared library. This approach:
- Avoids Java source generation for JCW method stubs
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand what you mean by this. .java files (and eventually .class and .dex files) are need by the Android run time in order to invoke the Java native method. The .java files exist to declare the native method!

How do you avoid "Java source generation"?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section clearly doesn't make sense. I'm not sure why I did not catch this earlier. Sorry for the confusion.

Comment on lines +180 to +182
// Array creation (AOT-safe)
// rank=1 for T[], rank=2 for T[][]
Array CreateArray(Type elementType, int length, int rank);
Copy link
Member Author

@simonrozsival simonrozsival Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this is outdated and it is actually not needed (replaced by JavaPeerContainerFactory). I need to double check.

Suggested change
// Array creation (AOT-safe)
// rank=1 for T[], rank=2 for T[][]
Array CreateArray(Type elementType, int length, int rank);

@simonrozsival
Copy link
Member Author

simonrozsival commented Feb 6, 2026

I don’t think we need the llvm il stuff or anything mono aot related — we aren’t changing the old system so I think it should work fine for older tfms without modification.

@agocke I'm not sure if I understand what you meant exactly. do you mean we could avoid generating any .ll code and use JNI's runtime native method registration mechanism?

using Java.Interop;

[A_Proxy]
unsafe class A_Proxy : JavaPeerProxy
{
    // Fully replaces GetFunctionPointer
    public override void RegisterNatives(JniType javaClass)
    {
        var args = stackalloc JniNativeMethodRegistration[1];
        args[0] = new JniNativeMethodRegistration("x"u8, "()V"u8, (IntPtr)(delegate*<..., void>)&X); // note: we don't have ReadOnlySpan<byte> overloads of JniNativeMethodRegistration yet in dotnet/java-interop, but we could easily add it
        type.RegisterNativeMethods(args);
    }

    [UnmanagedCallersOnly]
    private static void X(...) { ... }
}

Previously, I wanted to base the code on the marshal methods UCO + .ll trampolines + java native methods, but this registration pattern might be worth revisiting as it would avoid llc and native linking.

Changes:
- Rename JavaPeerProxy → JavaPeerProxyAttribute throughout (Thread 17)
- Rename className → jniName in ITypeMap and related signatures (Thread 10)
- Rename nc_activate_N → nctor_N for Java-callable constructors
- Clarify 'activation constructor' = (IntPtr, JniHandleOwnership) ctor (codebase convention)
- Introduce 'Java-callable constructor' term for user ctors invoked from Java
- Add NativeAOT to runtime selection check (Thread 11)
- Rename §10 to 'Java Callable Wrapper Constructor Generation' (Thread 20)
- Remove GetJniNamesForType from ITypeMap (Thread 9)
- Remove incorrect 'Avoids Java source generation' claim (Thread 30)
- Add generation origin callout block at top of §5 (Thread 15)
- Fix CreateInstance to use GetUninitializedObject + base .ctor pattern (Threads 12+16)
- Fix GetUninitialiedObject typos and return variable mismatch in §9
Add §4.2 explaining that generic user types (e.g., GenericHolder<T>)
DO get JCWs and TypeMap entries. TryGetType, TryGetJniNameForType, and
GetFunctionPointer all work normally. CreateInstance is unreachable
since the open-generic JCW has no Java-callable constructor.
Added generic row to §4.1 table.
- Swap §5 and §6: introduce JavaPeerProxyAttribute base class (now §5)
  before TypeMap Attributes (now §6) that use it
- Add IgnoresAccessChecksToAttribute explanation at top of new §5
- Add forward-reference notes in §4.1, §7, §8.1 for concepts defined later
- Fix stale section references (Section 21.6 → §5)
- Fix duplicate subsection numbers in §11 (was 10.x) and §13 (was 12.x)
- Fix §15 subsection numbering: sequential 15.1–15.9
- Fix §16 heading level (was ### instead of ##)
- Add Step 0 to runtime diagram: System.loadLibrary → JNI_OnLoad → TypeMap init (Thread 7)
- Clarify LLVM output: .so for MonoVM, .a merged into libApp.so for NativeAOT (Threads 4, 6, 25)
- Mark [Export] as low priority / not required for Android (Thread 1)
- Add accessibility note: IgnoresAccessChecksTo handles internal/private types (Threads 3, 19)
- Fix §17.7 table: accurate LLVM output and JNI registration per runtime
…lMethod

- §13.1: Split into Delegation (preferred) and Inlining (fallback) subsections
- Delegation: UCO delegates to generator-emitted n_* marshal method when available
- Inlining: Full BeginMarshalMethod/EndMarshalMethod pattern for [Export] and
  user-defined [Register] methods without generator callbacks
- §7.1: Updated proxy example to show delegation pattern
- §13.3: Updated blittable example with byte→bool conversion at boundary
- §15.9: Rewrite rule summary to explain MarkJavaObjects replacement
- Fix: Activity subclass without [Activity] is NOT unconditional —
  only types with component attributes or in AndroidManifest.xml are
- Clarify interface member preservation: all members must survive when
  interface survives, since Java calls through JCW
- Fix detection algorithm to check HasComponentAttribute/IsInManifest
  instead of just DoNotGenerateAcw
- Update summary table: user types without component attributes are
  trimmable (exceptions, interfaces, plain JLO subclasses)

Addresses PR review threads 28 and 29.
- Remove 'Not Yet Implemented' — this is now part of the spec
- Reframe as planned behavior, not a known gap
- Simplify workaround note as a callout block

Addresses PR review thread 2.
- Document JNI RegisterNatives with [UnmanagedCallersOnly] as alternative
  to LLVM IR stubs for binding Java native methods
- Compare build pipeline impact: eliminates llc compilation, native
  linking, post-trim .o filtering, and symbol export management
- Combined with trimmable typemap (eliminates native typemap tables),
  this removes 2 of 4 LLVM IR categories from the build
- Document prerequisites: java-interop API additions, registration
  trigger reuse, GC safety improvements
- Note path toward fully removing llc from builds in future releases

Addresses PR review threads on Debug build perf and avoid-llvm.
RegisterNatives is marginally faster than dlsym on hot path
(~328ns vs ~340ns per call on Samsung A16, MediaTek mt6789).
No reason to retain LLVM IR for marshal methods.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants