CommunityToolkit / CommunityToolkit/Maui

[BUG] [Android] TouchBehavior crashes with MissingMethodException when AccessibilityManager invokes a disposed AccessibilityListener (no (IntPtr, JniHandleOwnership) constructor)

Open
#3,317 0 comments 0 reactions 0 assignees View on GitHub
bug unverified
Dominant language
C#
Stars
2.7k
Forks
500
Avg merge
2d 14h
Merged PRs (30d)
7

Description

### Is there an existing issue for this?

- [x] I have searched the existing issues

### Did you read the "Reporting a bug" section on Contributing file?

- [x] I have read the "Reporting a bug" section on Contributing file: https://github.com/CommunityToolkit/Maui/blob/main/CONTRIBUTING.md#reporting-a-bug

### Current Behavior

Same root cause as #2383 and #2950, both auto-closed for lack of a reproduction. This report adds the mechanism, a public reproduction project with a deterministic path, and a proposed fix.

With any accessibility service running on Android (TalkBack, a screen reader, Switch Access), the app crashes with:

```
System.NotSupportedException: Unable to activate instance of type CommunityToolkit.Maui.Behaviors.TouchBehavior+AccessibilityListener from native handle ...
---> System.MissingMethodException: No constructor found for CommunityToolkit.Maui.Behaviors.TouchBehavior+AccessibilityListener::.ctor(System.IntPtr, Android.Runtime.JniHandleOwnership)
---> Java.Interop.JavaLocationException: Exception_WasThrown
java.lang.Error: Java callstack:
at crc640fd0ddb16fe433d4.TouchBehavior_AccessibilityListener.n_onAccessibilityStateChanged(Native Method)
at crc640fd0ddb16fe433d4.TouchBehavior_AccessibilityListener.onAccessibilityStateChanged(TouchBehavior_Accessibiliener.java:22)
at android.view.accessibility.AccessibilityManager.lambda$notifyAccessibilityStateChanged$0(AccessibilityManager.java:2488)
at android.view.accessibility.AccessibilityManager$$ExternalSyntheticLambda0.run(D8$$SyntheticClass:0)
at android.os.Handler.handleCallback(Handler.java:1095)
at android.os.Handler.dispatchMessageImpl(Handler.java:135)
at android.os.Handler.dispatchMessage(Handler.java:125)
at android.os.Looper.loopOnce(Looper.java:296)
at android.os.Looper.loop(Looper.java:397)
at android.app.ActivityThread.main(ActivityThread.java:9523)
```

It fires shortly after cold start when an accessibility service is already enabled (one crash per `TouchBehavior` being torn down while the first page is replaced) and also on background/resume, which matches the two earlier reports.

### Root cause

`TouchBehavior.android.cs` registers a private nested `AccessibilityListener : Java.Lang.Object` with the system `AccessibilityManager` in `OnAttachedTo`, and in `OnDetachedFrom` it removes the listener and immediately calls `accessibilityListener.Dispose()` on the managed peer.

Two things make that unsafe:

1. **Android delivers state changes from a snapshot.** `AccessibilityManager.notifyAccessibilityStateChanged()` copies the listener map under lock and then `post()`s one Runnable per listener to the main `Handler` (see [AOSP source](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/view/accessibility/AccessibilityManager.java;?q=notifyAccessibilityStateChanged)). Calling `removeAccessibilityStateChangeListener` after the snapshot does not cancel the pending Runnable, and the Runnable keeps the Java proxy alive.
2. **The managed peer cannot be re-created.** When the posted Runnable runs, the managed peer is already disposed, so .NET for Android tries to re-activate it through the `(IntPtr, JniHandleOwnership)` activation constructor. `AccessibilityListener` only has `internal AccessibilityListener(TouchBehavior)`, so `Java.Interop.TypeManager.CreateInstance` throws `MissingMethodException`, wrapped in `NotSupportedException`, which escapes the JNI callback and kills the process.

The window is short but it is hit reliably in two situations: app start with an accessibility service already enabled (the first `TouchBehavior` attach creates the process `AccessibilityManager`, which receives its initial state and broadcasts while the first page is being replaced), and resume from background (state is re-broadcast).

There is also a deterministic variant: `TouchBehavior.Dispose()` → `PlatformDispose()` disposes `accessibilityListener` **without** unregistering it from `accessibilityManager`, so the next state change always calls the disposed peer.

The identical bug existed in Xamarin Community Toolkit's `TouchEffect` and was fixed by adding the activation constructor: https://github.com/xamarin/XamarinCommunityToolkit/commit/33c36af2c018d7981eb0931bfcd160168c77a38f

### Expected Behavior

A state-change callback arriving for a detached or disposed `TouchBehavior` is a no-op. The app does not crash.

### Steps To Reproduce

The linked repository contains a minimal Android-only MAUI app plus a `repro.sh` script. Both paths below were verified on an Android 13 (API 33) emulator with TalkBack.

1. `dotnet build -f net10.0-android -t:Install`
2. `./repro.sh simulate` (no TalkBack needed) prints:

```
System.NotSupportedException: Unable to activate instance of type CommunityToolkit.Maui.Behaviors.TouchBehavior+AccessibilityListener from native handle 0x7ff7a29a98 (key_handle 0x8732737).
--> System.MissingMethodException: No constructor found for CommunityToolkit.Maui.Behaviors.TouchBehavior+AccessibilityListener::.ctor(System.IntPtr, Android.Runtime.JniHandleOwnership)
--> Java.Interop.JavaLocationException: Exception of type 'Java.Interop.JavaLocationException' was thrown.
```

3. `./repro.sh` (enables TalkBack through adb) crashes the process through the real `AccessibilityManager` dispatch:

```
FATAL EXCEPTION: main
android.runtime.JavaProxyThrowable: [System.NotSupportedException]: Unable to activate instance of type CommunityToolkit.Maui.Behaviors.TouchBehavior+AccessibilityListener from native handle ...
at Java.Interop.TypeManager.CreateInstance + 0x2b2(Unknown Source)
at Android.Runtime.AndroidValueManager.CreatePeer + 0xa(Unknown Source)
at Java.Interop.JniRuntime+JniValueManager.GetPeer + 0x48(Unknown Source)
at Java.Lang.Object.GetObject + 0xa(Unknown Source)
at Android.Views.Accessibility.AccessibilityManager+IAccessibilityStateChangeListenerInvoker.n_OnAccessibilityStated_Z + 0xe(Unknown Source)
at crc640fd0ddb16fe433d4.TouchBehavior_AccessibilityListener.n_onAccessibilityStateChanged(Native Method)
at crc640fd0ddb16fe433d4.TouchBehavior_AccessibilityListener.onAccessibilityStateChanged(TouchBehavior_Accessibiliener.java:31)
at android.view.accessibility.AccessibilityManager.lambda$notifyAccessibilityStateChanged$0(AccessibilityManager.java:1881)
at android.os.Handler.handleCallback(Handler.java:942)
at android.os.Looper.loopOnce(Looper.java:201)
```

Manual alternative: launch the app, tap **1** (detach), then tap **2a** (exception shown on screen) or tap **2b** and toggle TalkBack in Settings > Accessibility (app crashes).

What the app does, so the mechanism is explicit:

1. A `Border` has a `TouchBehavior` attached. Before detaching, the page takes a JNI global reference to the toolkit's Java `AccessibilityListener` object (read through reflection). This stands in for the Runnable that `AccessibilityManager` posts to the main looper, which holds the same reference in the real-world race.
2. `border.Behaviors.Remove(touchBehavior)`: the toolkit's `OnDetachedFrom` unregisters the listener and disposes the managed peer. The Java object survives.
3. Either call `onAccessibilityStateChanged(true)` on that Java object directly (`simulate`), or add it back to the process `AccessibilityManager` via JNI and toggle TalkBack so the system dispatches to it exactly as in production.

Side note: calling the public `TouchBehavior.Dispose()` while attached also leaves the listener registered with a disposed peer (`PlatformDispose()` disposes without unregistering), but it additionally disposes the host view's platform peer, so the next layout pass dies with a `NullPointerException` in `PlatformInterop.measureAndGetWidthAndHeight` first. That looks like a separate defect.

### Link to public reproduction project repository

https://github.com/mobliam/TouchBehaviorA11yRepro.git

### Environment

```markdown
- .NET MAUI CommunityToolkit: 15.0.1 (the `main` branch still has no activation constructor as of d8a05a4)
- OS: Android 16 (production) and Android 13 emulator API 33 (reproduction), TalkBack enabled; earlier reports cover Android 11–15
- .NET MAUI: 10.0.100 / .NET 10
```

### Anything else?

Proposed fix, in `src/CommunityToolkit.Maui/Behaviors/PlatformBehaviors/Touch/TouchBehavior.android.cs`:

```csharp
sealed class AccessibilityListener : Java.Lang.Object,
AccessibilityManager.IAccessibilityStateChangeListener,
AccessibilityManager.ITouchExplorationStateChangeListener
{
// Nullable: an instance re-activated from a Java handle has no owning behavior.
readonly WeakReference? platformTouchBehaviorReference;

internal AccessibilityListener(TouchBehavior platformTouchBehavior)
{
platformTouchBehaviorReference = new(platformTouchBehavior);
}

// Invoked by the runtime when AccessibilityManager calls back into a listener whose
// managed peer was already disposed (callback posted before the listener was removed).
public AccessibilityListener(IntPtr handle, JniHandleOwnership transfer) : base(handle, transfer)
{
}

public void OnAccessibilityStateChanged(bool enabled) => UpdateClickHandler();

public void OnTouchExplorationStateChanged(bool enabled) => UpdateClickHandler();

void UpdateClickHandler()
{
if (platformTouchBehaviorReference?.TryGetTarget(out var platformTouchBehavior) is true)
{
platformTouchBehavior.UpdateClickHandler();
}
}

protected override void Dispose(bool disposing)
{
if (disposing)
{
platformTouchBehaviorReference?.SetTarget(null);
}

base.Dispose(disposing);
}
}
```

and in `PlatformDispose()` unregister before disposing:

```csharp
if (accessibilityManager is not null && accessibilityListener is not null)
{
accessibilityManager.RemoveAccessibilityStateChangeListener(accessibilityListener);
accessibilityManager.RemoveTouchExplorationStateChangeListener(accessibilityListener);
}
accessibilityListener?.Dispose();
accessibilityListener = null;
accessibilityManager?.Dispose();
accessibilityManager = null;
```

Happy to open a PR with this change if maintainers agree with the approach.

Contributor guide

Open the contributing guide

Research direction

Start in src/CommunityToolkit.Maui/Behaviors/PlatformBehaviors/Touch/TouchBehavior.android.cs, focusing on AccessibilityListener, OnDetachedFrom, and PlatformDispose(). Use the linked reproduction project's repro.sh simulate path and the described Android build/install command to verify the failure. Done means callbacks for detached or disposed behaviors become no-ops and the Android app no longer crashes during the reproduction paths.

Written by the indexing model from the issue text.

Assessment

Tech stack
android, csharp
Domain
mobile-dev
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.