You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 classColor(valvalue:ULong)
@JvmInline value classDp(valvalue:Float)
funtint(color:Color) { … } // JVM name: tint-<hash>funpad(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:
Handle Kotlin name-mangled methods #534 "Handle Kotlin name-mangled methods" (closed 2019-12-09) — implemented the single-method rename foo-WZ4Q5Ns → Foo and marked the method non-virtual (because JCWs can't emit a - in a Java identifier).
Kotlin 1.6 metadata sometimes isn't applied #945 "Kotlin 1.6 metadata sometimes isn't applied" (closed) — current class-parse already reads hashed methods like get-pVg5ArA and matches them against @kotlin.Metadata for type fixups.
Two cases were never handled, and they're exactly what Jetpack Compose triggers
at scale:
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.
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.Metadatakind == 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.
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:
In
.classbytecode the methods appear astint-XXXXX(J)Vandpad-XXXXX(F)F. Kotlin source callingtint(Color(0xFF00FF00UL))is rewritten at the call site totint-XXXXX(0xFF00FF00L).This convention is used everywhere in Jetpack Compose. Every
@Composablefunction with an inline-class parameter —Color,Dp,TextStyle,ButtonColors,PaddingValues(carriesDpinsets),TextUnit,Offset,IntOffset,Size,IntSize,Constraints, ... — gets a hash-mangled JVM name. Examples fromandroidx.compose.material31.3.2:Button-XXXXX(...)VBasicText-BpD7jsM(...)VText-XXXXX(...)VCard-XXXXX(...)VPrior art
This issue is a follow-up to closed work from 2019/2020 that handled the
single-sibling case:
foo-WZ4Q5Nsexists and that we'd need follow-up.foo-WZ4Q5Ns→Fooand marked the method non-virtual (because JCWs can't emit a-in a Java identifier).class-parsealready reads hashed methods likeget-pVg5ArAand matches them against@kotlin.Metadatafor type fixups.Two cases were never handled, and they're exactly what Jetpack Compose triggers
at scale:
Button-A1B2C3,Button-X4Y5Z6,Button-...) — common for any composable with several inline-class parameter sets — the rename produces several methods all namedButtonwith identical C#-erased signatures, triggering CS0111 in generated code. Today every Compose binding (compose-net, the localXamarin.AndroidX.Compose.*recipes, etc.) works around this with a<remove-node path=".../method[starts-with(@name,'methodName-')]" />blanket strip.longforColor,floatforDp, ...) 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.Material3Androidcurrently strips all@Composablefunctions 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 incompose-netfor what callers have to do today to invokeButton/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 itso that when N>1 hashed siblings of the same Kotlin source-name exist:
Metadata.xmldecision.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 structwrapper (Color,Dp,TextUnit, ...) that:Equals/GetHashCode/ToStringto the primitive,This is the part that makes
Button(onClick, modifier, enabled: true, shape, colors, ...)callable from C# with type-safeColorandDpparameters instead of barelong/float.Detection: a class is an inline class iff its
@kotlin.Metadatakind == 1(class) AND its annotation list containskotlin.jvm.JvmInlineAND 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-parsealready reads@kotlin.Metadataand 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.generatorwould need to: (a) emit the C#readonly structwrappers 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)
$defaultmask parameter for Kotlin default args. Compose's@Composablelowering also adds a trailingint $defaultmask parameter (separate from default-argfoo$defaultsiblings). That's a separate generator concern — useful but not required to unblock Add java-interop-reviewer skill for PR code reviews #1417.@Composablesemantics. Generating thestartRestartGroup/endRestartGroup/$changedbitmask plumbing is a Roslyn-source-generator-shaped problem and lives outsidedotnet/java-interop. See the compose-net README for the tier-2 sketch.@Composableparams. Kotlin's@Composableis a type annotation; there's no syntactic equivalent in C#. Out of scope here.Acceptance criteria
androidx.compose.material3:material3-android1.3.2 with no manual<remove-node>for hashed-name siblings produces aButtonKtwhoseButtonoverload set is reachable from C#.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.tests/generator-Testscovering: simple inline-class param, multiple-hash overloads of the same Kotlin name, inline class with non-long/floatbacking field, and collision case where two overloads erase to the same C# signature.References
<remove-node>triage today that this generator change would let us simplify.compose-netMaterial3 binding — current<remove-node>workarounds.compose-netraw-JNI fallback forButton/Text— what callers have to do today.compose-netNOTES.md — full characterization of the inline-class binding-time errors (surprisesJavaException.InnerExceptionshould returnThrowable.getCause(). #1, Rename JavaVM.GetObject() to JavaVM.GetValue(). #2; open issues Rename JavaVM.GetObject() to JavaVM.GetValue(). #2, Permit hash conflicts in instance mapping #5, Use Performance Counters to track JNI handle counts, values. #9, Type coercion and JavaCast<T> support #10).