dotnet / dotnet/android

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

Open
#11,844 0 comments 0 reactions 0 assignees View on GitHub
java-interop needs-triage
Dominant language
C#
Stars
2.1k
Forks
579
Avg merge
1d 19h
Merged PRs (30d)
252

Description

## Background

Kotlin's [inline classes](https://kotlinlang.org/docs/inline-classes.html) (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:

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

fun tint(color: Color) { … } // JVM name: tint-
fun pad(dp: Dp): Dp = dp // JVM name: pad-, 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:

- **#525 "Kotlin & method overloading & name mangling, oh my!"** (closed 2020-01-07) — established that `foo-WZ4Q5Ns` exists and that we'd need follow-up.
- **#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).
- **#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:

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 `` 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](https://github.com/dotnet/android-libraries/issues/1417) for the consuming-binding side, and the [working raw-JNI fallback in `compose-net`](https://github.com/jonathanpeppers/compose-net/blob/d1c6a5ecd3b02f624c2e5e4655a93b36126277b4/src/ComposeNet.Compose/ComposeBridges.cs) 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 dotnet/java-interop#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 `` 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 dotnet/java-interop#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](https://github.com/jonathanpeppers/compose-net/blob/d1c6a5ecd3b02f624c2e5e4655a93b36126277b4/README.md) 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 `` 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 `` 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

- Prior closed work: dotnet/java-interop#525, dotnet/java-interop#534, dotnet/java-interop#945.
- [dotnet/android-libraries#1417](https://github.com/dotnet/android-libraries/issues/1417) — consuming binding side; "Option B" in that issue is exactly this proposal.
- [dotnet/android-libraries#1415](https://github.com/dotnet/android-libraries/issues/1415), [dotnet/android-libraries#1416](https://github.com/dotnet/android-libraries/issues/1416) — sibling Compose-binding issues; both rely on `` triage today that this generator change would let us simplify.
- [Kotlin inline classes spec](https://kotlinlang.org/docs/inline-classes.html#mangling).
- [`compose-net` Material3 binding](https://github.com/jonathanpeppers/compose-net/blob/d1c6a5ecd3b02f624c2e5e4655a93b36126277b4/src/ComposeNet.Bindings.Material3/Transforms/Metadata.xml) — current `` workarounds.
- [`compose-net` raw-JNI fallback for `Button`/`Text`](https://github.com/jonathanpeppers/compose-net/blob/d1c6a5ecd3b02f624c2e5e4655a93b36126277b4/src/ComposeNet.Compose/ComposeBridges.cs) — what callers have to do today.
- [`compose-net` NOTES.md](https://github.com/jonathanpeppers/compose-net/blob/d1c6a5ecd3b02f624c2e5e4655a93b36126277b4/NOTES.md) — full characterization of the inline-class binding-time errors (surprises dotnet/java-interop#1, dotnet/java-interop#2; open issues dotnet/java-interop#2, dotnet/java-interop#5, dotnet/android#11940, dotnet/java-interop#10).

Contributor guide

No contributing guide indexed for this repository

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.