Skip to content

[generator] Demangle Kotlin inline-class hashed method names and project inline-class params as their underlying primitives #1431

@jonathanpeppers

Description

@jonathanpeppers

Background

Kotlin's inline classes (a.k.a. value classes) are erased at the JVM level to their single backing field's type, but the compiler mangles the JVM method name with a hash suffix any time an inline-class type appears in the parameter list. For example:

@JvmInline value class Color(val value: ULong)
@JvmInline value class Dp(val value: Float)

fun tint(color: Color) { … }        // JVM name: tint-<hash>
fun pad(dp: Dp): Dp = dp            // JVM name: pad-<hash>, returns long

In .class bytecode the methods appear as tint-XXXXX(J)V and pad-XXXXX(F)F. Kotlin source calling tint(Color(0xFF00FF00UL)) is rewritten at the call site to tint-XXXXX(0xFF00FF00L).

This convention is used everywhere in Jetpack Compose. Every @Composable function with an inline-class parameter — Color, Dp, TextStyle, ButtonColors, PaddingValues (carries Dp insets), TextUnit, Offset, IntOffset, Size, IntSize, Constraints, ... — gets a hash-mangled JVM name. Examples from androidx.compose.material3 1.3.2:

  • Button-XXXXX(...)V
  • BasicText-BpD7jsM(...)V
  • Text-XXXXX(...)V
  • Card-XXXXX(...)V

Prior art

This issue is a follow-up to closed work from 2019/2020 that handled the
single-sibling case:

Two cases were never handled, and they're exactly what Jetpack Compose triggers
at scale:

  1. Multi-sibling collision. When multiple hashed siblings of the same Kotlin source-name coexist (Button-A1B2C3, Button-X4Y5Z6, Button-...) — common for any composable with several inline-class parameter sets — the rename produces several methods all named Button with identical C#-erased signatures, triggering CS0111 in generated code. Today every Compose binding (compose-net, the local Xamarin.AndroidX.Compose.* recipes, etc.) works around this with a <remove-node path=".../method[starts-with(@name,'methodName-')]" /> blanket strip.
  2. Inline-class param surfacing. Even when a single-sibling rename succeeds, the C# parameter type is the erased JVM type (long for Color, float for Dp, ...) with no indication that the caller is supposed to construct it via the inline class's factory (Color-impl.constructor-impl(long)). Callers have to memorise the convention per type. Compose has ~15 inline classes on its public surface.

Current symptom in consuming bindings

Xamarin.AndroidX.Compose.Material3Android currently strips all @Composable functions even though the recipe successfully binds 265 other types — both cases above hit at once. See dotnet/android-libraries#1417 for the consuming-binding side, and the working raw-JNI fallback in compose-net for what callers have to do today to invoke Button/Text.

Proposal

Two cooperating generator features. They can ship independently — (1) is the unblocker, (2) is the polish.

(1) Handle the multi-sibling collision case

The single-sibling rename from #534 already runs in class-parse. Extend it
so that when N>1 hashed siblings of the same Kotlin source-name exist:

  • Continue renaming all of them to the unmangled name.
  • Detect collision: if two renamed methods have C#-identical parameter lists (after the inline-class → underlying-primitive projection from step 2), do not silently emit both — keep both in the AST but mark them as "duplicate inline-class overload" so the generator can either skip with a deterministic policy or emit a warning that drives a Metadata.xml decision.
  • Without step 2, the collision check is "after parameter projection only" — i.e. fall back to dropping all but the first.

This alone removes the <remove-node path=".../method[starts-with(@name,'methodName-')]" /> workaround from every Compose binding today.

(2) Project inline-class params/returns as their underlying type, with strongly-typed C# wrappers

For each inline class on the binding's input surface, emit a C# readonly struct wrapper (Color, Dp, TextUnit, ...) that:

  • holds the single backing primitive,
  • exposes implicit conversions to/from the primitive,
  • delegates Equals/GetHashCode/ToString to the primitive,
  • is recognized by the generator so that methods declaring the inline class as a parameter accept the struct (and the generated JNI thunk passes the primitive).

This is the part that makes Button(onClick, modifier, enabled: true, shape, colors, ...) callable from C# with type-safe Color and Dp parameters instead of bare long/float.

Detection: a class is an inline class iff its @kotlin.Metadata kind == 1 (class) AND its annotation list contains kotlin.jvm.JvmInline AND it declares a single non-synthetic instance field. The backing field's JVM type is the erased projection.

Where this fits in the pipeline

  • class-parse already reads @kotlin.Metadata and already records the original mangled name on each method (used today by single-sibling rename). It would need to: (a) recognize @JvmInline, (b) keep all siblings in the AST instead of letting renames collide.
  • generator would need to: (a) emit the C# readonly struct wrappers for inline classes referenced on the binding's public surface, (b) use those wrapper types in generated method signatures while continuing to pass the erased primitive across JNI, (c) deterministically resolve any post-projection signature collisions.

Out of scope (intentionally)

  • $default mask parameter for Kotlin default args. Compose's @Composable lowering also adds a trailing int $default mask parameter (separate from default-arg foo$default siblings). That's a separate generator concern — useful but not required to unblock Add java-interop-reviewer skill for PR code reviews #1417.
  • @Composable semantics. Generating the startRestartGroup / endRestartGroup / $changed bitmask plumbing is a Roslyn-source-generator-shaped problem and lives outside dotnet/java-interop. See the compose-net README for the tier-2 sketch.
  • Kotlin function-typed @Composable params. Kotlin's @Composable is a type annotation; there's no syntactic equivalent in C#. Out of scope here.

Acceptance criteria

  • Binding androidx.compose.material3:material3-android 1.3.2 with no manual <remove-node> for hashed-name siblings produces a ButtonKt whose Button overload set is reachable from C#.
  • Binding any other AAR that uses inline classes (androidx.compose.ui:ui-android, androidx.compose.foundation:foundation-android) no longer needs the <remove-node path="...class[@name='Kt']/method[starts-with(@name,'methodName-')]" /> triage entries that compose-net and dotnet/android-libraries currently maintain.
  • Existing Java/Kotlin bindings that do not use inline classes show no public-API churn.
  • New unit tests in tests/generator-Tests covering: simple inline-class param, multiple-hash overloads of the same Kotlin name, inline class with non-long/float backing field, and collision case where two overloads erase to the same C# signature.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions