dotnet / dotnet/android-libraries

Kotlin `object` companion field not surfaced on outer class binding (KeyboardOptions.Companion, TextStyle.Companion, ...)

Open
#1,467 0 comments 1 reaction 2 assignees Claimed by @jonathanpeppers View on GitHub
Dominant language
C#
Stars
317
Forks
73
Avg merge
1d 8h
Merged PRs (30d)
17

Description

### Summary

Kotlin `object` companion fields aren't surfaced as public properties on the outer class binding, so consumers can't reach the singleton through the binding alone. They have to fall back to raw JNI (`FindClass` + `GetStaticFieldID("Companion")` + `GetStaticObjectField`) to bootstrap the companion peer.

### Repro

Pick any AndroidX type with a Kotlin `object` companion. `androidx.compose.foundation.text.KeyboardOptions` is the case I just hit:

```kotlin
public class KeyboardOptions /* ... */ {
public companion object {
public val Default: KeyboardOptions = KeyboardOptions(/* ... */)
}
}
```

Kotlin lowers that to bytecode:

```text
public final class androidx.compose.foundation.text.KeyboardOptions {
public static final androidx.compose.foundation.text.KeyboardOptions$Companion Companion;
public static final androidx.compose.foundation.text.KeyboardOptions$Companion access$getCompanion$cp();
...
}
```

After binding through `Xamarin.AndroidX.Compose.Foundation 1.11.2.2`:

* `AndroidX.Compose.Foundation.Text.KeyboardOptions` — bound (sealed class)
* `KeyboardOptions.Companion` nested type — bound with `[Register("androidx/compose/foundation/text/KeyboardOptions$Companion", DoNotGenerateAcw = true)]`
* `Companion.Default` — bound (instance getter, returns `KeyboardOptions`)
* `KeyboardOptions.Copy(int, Boolean?, int, int, ...)` — bound

What's missing:

* **No `KeyboardOptions.Companion` static property/field accessor on the outer class.** Decompiled the binding's outer `KeyboardOptions` and the `Companion` field is dropped — there's no `public static KeyboardOptions.Companion Companion { get; }` member to call.
* No public ctor (stripped — `@JvmInline value class` params hit the `-d9O1mEE` mangling).
* No `KeyboardOptionsKt` static helper.

So even though the entire downstream API (`Companion.Default`, `Copy(...)`) is bound, there's no binding-only way to *get* the `Companion` instance to start the call chain.

### Current workaround

Hand-roll the singleton bootstrap once per affected type. Verbatim from [`Microsoft.AndroidX.Compose/src/Microsoft.AndroidX.Compose/KeyboardOptionsCompanion.cs`](https://github.com/jonathanpeppers/Microsoft.AndroidX.Compose/blob/main/src/Microsoft.AndroidX.Compose/KeyboardOptionsCompanion.cs):

```csharp
IntPtr cls = JNIEnv.FindClass("androidx/compose/foundation/text/KeyboardOptions");
IntPtr field = JNIEnv.GetStaticFieldID(cls, "Companion",
"Landroidx/compose/foundation/text/KeyboardOptions$Companion;");
IntPtr local = JNIEnv.GetStaticObjectField(cls, field);
s_companion_ref = JNIEnv.NewGlobalRef(local);
JNIEnv.DeleteLocalRef(local);
return new Companion(s_companion_ref, JniHandleOwnership.DoNotTransfer);
```

Everything downstream (`Default`, `Copy(...)`) is the binding — only the static-field read needs JNI. We already have the same workaround for `androidx.compose.ui.text.TextStyle.Companion` (`TextStyleCompanion.cs`) and need it for several more (`ContentScale.Companion`, `Alignment.Companion`, `FontWeight.Companion`, etc.).

### Proposed fix

Single Metadata.xml fix-up per `androidx.compose.*` transform that has a Kotlin `object` companion. For `KeyboardOptions`:

```xml

true
```

After which the binder should emit `public static KeyboardOptions.Companion Companion { get; }` on the outer class, and consumers can write `KeyboardOptions.Companion.Default` directly. We'd then delete the hand-rolled JNI bootstrap files.

### Possible complication

The binder sometimes hides Companion fields because the property name collides with the nested type name (you end up with two `Companion` members at the same scope — one type, one property). C# handles that fine, but the generator may have proactively dropped the field to avoid the conflict. If that's the cause, the fix becomes either:

* `CompanionInstance` to rename the property, or
* `` + `` injecting an explicit property body.

Both still live in Metadata.xml — no generator code change needed.

### Affected packages

Every Compose package that exposes a Kotlin `object` companion. Most-impactful ones I've already had to bootstrap by hand or know I'll need to:

* `Xamarin.AndroidX.Compose.Foundation` — `KeyboardOptions.Companion`
* `Xamarin.AndroidX.Compose.UI.Text` — `TextStyle.Companion`, `TextDecoration.Companion`, `FontWeight.Companion`, `FontFamily.Companion`, `FontStyle.Companion`
* `Xamarin.AndroidX.Compose.UI` — `Alignment.Companion`, `Modifier.Companion`, `ContentScale.Companion`
* `Xamarin.AndroidX.Compose.UI.Graphics` — `Color.Companion`, `Shape.Companion` family
* `Xamarin.AndroidX.Compose.Material3` — `MaterialTheme.Companion`, many of the `*Defaults` types

Not Compose-specific — the same gap should exist on any Kotlin-authored AndroidX type with an `object` companion.

### Related

* [`jonathanpeppers/Microsoft.AndroidX.Compose#246`](https://github.com/jonathanpeppers/Microsoft.AndroidX.Compose/pull/246) — most recent occurrence (`KeyboardOptionsCompanion.cs`).
* `TextStyleCompanion.cs` in the same repo — first occurrence; we hit the identical pattern in Phase 1 and shipped the same JNI workaround.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.