CommunityToolkit / CommunityToolkit/Maui
[BUG] [Android] TouchBehavior crashes with MissingMethodException when AccessibilityManager invokes a disposed AccessibilityListener (no (IntPtr, JniHandleOwnership) constructor)
- 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
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