Skip to content

Xamarin.AndroidX.Compose.Foundation: MutationInterruptedException is dropped during binding #1457

@jonathanpeppers

Description

@jonathanpeppers

Package

Xamarin.AndroidX.Compose.Foundation / Xamarin.AndroidX.Compose.Foundation.Android

Version

1.11.1 (also reproduces on 1.10.4.1)

Summary

androidx.compose.foundation.MutationInterruptedException is present in the upstream JAR (androidx/compose/foundation/MutationInterruptedException.class) but is silently dropped during binding. The class is not removed by source/androidx.compose.foundation/foundation-android/Transforms/Metadata.xml, yet zero references to it appear in the consumer-built Xamarin.AndroidX.Compose.Foundation.Android.dll.

Sibling types in the same MutatorMutex.kt file bind correctly:

Symbol Bound?
androidx.compose.foundation.MutatorMutex
androidx.compose.foundation.MutatePriority
androidx.compose.foundation.internal.PlatformOptimizedCancellationException (the parent)
androidx.compose.foundation.MutationInterruptedException

Why it matters

MutationInterruptedException is what Compose throws into await-able state-mutation APIs (e.g. ScrollState.animateScrollTo, ScrollState.scrollTo, LazyListState.animateScrollToItem, AnchoredDraggableState.anchoredDrag, all Scrollable*.scroll(...) paths) when a higher-priority caller wins the MutatorMutex mid-flight — most commonly when the user touches the scroll surface during a programmatic animated scroll, which ejects the running animation.

Without the binding, C# callers using kotlinx.coroutines interop have no typed exception to catch and must fall back to catch (Java.Lang.Throwable), which over-catches and is what most Android-style code-review guidance discourages. With the binding, they could write the natural:

try
{
    await scrollState.AnimateScrollToAsync(targetValue, ct);
}
catch (MutationInterruptedException)
{
    // gesture or higher-priority mutation won; ignore
}

This is the same gap referenced in #1456 (the C# scroll-suspend reproduction) and #1440 (binder mangled-name handling) -- this one is more narrowly about a single missing public type.

Reproduction

Disassemble the bound DLL after a consumer build:

$dll = "src/<YourApp>/obj/Debug/net10.0-android/android/assets/arm64-v8a/Xamarin.AndroidX.Compose.Foundation.Android.dll"
$bytes = [IO.File]::ReadAllBytes($dll)
foreach ($needle in "MutationInterruptedException","MutatorMutex","PlatformOptimizedCancellation") {
    $pattern = [Text.Encoding]::ASCII.GetBytes($needle)
    $count = 0
    for ($i = 0; $i -lt $bytes.Length - $pattern.Length; $i++) {
        $m = $true
        for ($j = 0; $j -lt $pattern.Length; $j++) { if ($bytes[$i+$j] -ne $pattern[$j]) { $m = $false; break } }
        if ($m) { $count++; $i += $pattern.Length }
    }
    "$needle : $count"
}

Output:

MutationInterruptedException : 0
MutatorMutex                 : 6
PlatformOptimizedCancellation : 2

Confirm the class IS in the upstream JAR by extracting androidx.compose.foundation.foundation-android.aar -> classes.jar and running:

javap -p -s androidx/compose/foundation/MutationInterruptedException.class
Compiled from "MutatorMutex.kt"
public final class androidx.compose.foundation.MutationInterruptedException
    extends androidx.compose.foundation.internal.PlatformOptimizedCancellationException {
  public static final int $stable;
  public androidx.compose.foundation.MutationInterruptedException();
  static {};
}

The class is public final, has a public no-arg ctor, and extends a Kotlin abstract class that has both a public (String) ctor and the synthetic (String, int, DefaultConstructorMarker) no-arg helper.

Suspected cause

The parent PlatformOptimizedCancellationException declares its no-arg ctor via the Kotlin synthetic-default pattern ((String s, int default, DefaultConstructorMarker marker) + a public X() synthetic). The binder typically surfaces only the explicit (String) overload on the parent, so the subclass loses its only chainable base ctor and gets silently dropped by a later validation pass.

(Sibling types like MutatorMutex and MutatePriority bind fine because their parents have unambiguous accessible base ctors.)

Proposed fix

If the root cause is the dropped no-arg base ctor, adding a <add-node> in source/androidx.compose.foundation/foundation-android/Transforms/Metadata.xml may reintroduce the class. Rough shape:

<add-node path="/api/package[@name='androidx.compose.foundation']">
  <class abstract="false" deprecated="not deprecated" final="true"
         name="MutationInterruptedException" static="false" visibility="public"
         extends="androidx.compose.foundation.internal.PlatformOptimizedCancellationException"
         extends-generic-aware="androidx.compose.foundation.internal.PlatformOptimizedCancellationException"
         jni-extends="Landroidx/compose/foundation/internal/PlatformOptimizedCancellationException;"
         jni-signature="Landroidx/compose/foundation/MutationInterruptedException;">
    <constructor deprecated="not deprecated" final="false"
                 name="MutationInterruptedException" static="false"
                 visibility="public" bridge="false" synthetic="false" />
  </class>
</add-node>

Alternatively, restoring the no-arg ctor on the parent (so the subclass binds naturally) would be cleaner if the binder allows surfacing the synthetic.

Workaround

C# callers can still catch the exception, just not by type:

try
{
    await scrollState.AnimateScrollToAsync(targetValue, ct);
}
catch (Java.Lang.Throwable t) when (t.Class.Name == "androidx.compose.foundation.MutationInterruptedException")
{
    // gesture won the mutex
}

This works but is fragile, ugly, and discoverability is poor.

Environment

  • .NET 10 / net10.0-android36.0
  • Xamarin.AndroidX.Compose.Foundation 1.11.1
  • Xamarin.AndroidX.Compose.Foundation.Android 1.11.1
  • Discovered while building jonathanpeppers/compose-net, specifically the SuspendBridge -> ScrollState.AnimateScrollToAsync flow.

Metadata

Metadata

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