Skip to content

Android: fix crash on a stale accessibility virtual view id - #22122

Merged
MrJul merged 4 commits into
AvaloniaUI:mainfrom
ronnycohen:fix/android-a11y-empty-node-description
Sep 2, 2026
Merged

Android: fix crash on a stale accessibility virtual view id#22122
MrJul merged 4 commits into
AvaloniaUI:mainfrom
ronnycohen:fix/android-a11y-empty-node-description

Conversation

@ronnycohen

@ronnycohen ronnycohen commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

What does the pull request do?

Fixes a crash on Android when the accessibility framework asks for a virtual view whose peer is no longer registered.

What is the current behavior?

ExploreByTouchHelper.createNodeForChild rejects a node whose text and content description are both null, and throws: "Callbacks must add text or a content description in populateNodeForVirtualViewId()"

OnPopulateNodeForVirtualView returns without touching the node when the id is not found, so androidx gets an empty node and throws. It throws inside an accessibility callback, so the app cannot catch it and the process dies.

That path became reachable with #22024, which unregisters a peer when its control leaves the visual tree. The platform caches nodes it obtained earlier and re-queries the accessibility focused one, so a lookup can fail after a detach.

What is the updated/expected behavior with this PR?

An unknown id gets an inert node: empty description, disabled, not focusable, empty bounds. The traversal completes instead of killing the process.

How was the solution implemented (if it's not obvious)?

The guard in androidx.customview 1.1.0 and 1.2.0 is a strict null check, so string.Empty satisfies it.

Checklist

  • Added unit tests (if possible)? Not possible, the path needs a device with an
    accessibility service running.
  • Added XML documentation to any related classes? No public API added.
  • Consider submitting a PR to avalonia-docs. Not applicable.

Breaking changes

None.

Obsoletions / Deprecations

None.

Fixed issues

The type name default discussed earlier has been removed from this PR.

…scription

`ExploreByTouchHelper.createNodeForChild` validates every virtual view the
callback produces and throws

    Callbacks must add text or a content description in populateNodeForVirtualViewId()

when the node carries neither. It throws from inside an accessibility callback,
so the exception is not recoverable by the application: the process goes down.

`TextUtils.isEmpty` treats `""` as empty, so an empty string does not satisfy
the contract - only a non-empty value does. Two paths in
`OnPopulateNodeForVirtualView` could produce such a node:

* **A live peer with no accessible name.** `nodeInfo.Text ??= peer.GetName()`
  looks like it only assigns when nothing was set, but `AutomationPeer.GetName()`
  and `GetHelpText()` never return null - they collapse a missing value to
  `string.Empty` - so both assignments always run and can both assign `""`.
  This happens for any peer that is a pure container: a `Panel`, a `Border`, or
  the `TextSelectorLayer` that is added when a text selection starts. On a device
  with an accessibility service running, the first touch on a text field was
  enough to bring the application down.

* **A stale virtual view id.** Since AvaloniaUI#22024 peers are unregistered when their
  control leaves the visual tree, and the platform can still ask for a node it
  obtained earlier - it caches them, and it re-queries the accessibility focused
  one. The lookup then fails and the node was left untouched, which the same
  validation rejects.

Both are fixed by guaranteeing a non-empty content description. A single space
is used deliberately: it satisfies the platform contract without inventing a
label that screen readers would announce.

Note that `AutomationPeer.GetClassName()` has the same `?? string.Empty` shape,
so it cannot serve as the fallback.

### Testing

`Avalonia.Android` builds clean. The crash paths need a device with an
accessibility service enabled and are not reachable from the unit test projects;
the fix was verified on hardware (Android 13 kiosk) where the first touch on a
text field used to take the application down on every armed process.
@Gillibald

Copy link
Copy Markdown
Contributor

The question is why we don't enforce a description or name. It should be at least the type name by default.

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.2.999-cibuild0069266-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

… attribution

Review feedback: a single space only satisfies the platform contract, it does not
describe anything, and it hides an unnamed control instead of surfacing it. The
fallback is available - GetClassNameCore() is abstract and ControlAutomationPeer
returns Owner.GetType().Name - so a peer always has a type name, which is also what
nodeInfo.ClassName already carries.

This also corrects a claim made in the first revision of this change. The androidx
guard does not use TextUtils.isEmpty: in androidx.customview 1.1.0 and 1.2.0,
createNodeForChild tests getText() and getContentDescription() for null, strictly,
and the class contains no TextUtils reference at all (the isEmpty guard sits on the
event path, which throws populateEventForVirtualViewId()).

Since AutomationPeer.GetName() collapses a missing name to string.Empty, a node
built for an unnamed container was therefore never rejected. Only the stale virtual
view id path - reachable since AvaloniaUI#22024 unregisters peers on detach - produced a node
the platform refuses. The placeholder there is now string.Empty, and the type name
default is presented for what it is: an accessibility improvement, not a crash fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ronnycohen ronnycohen changed the title Android: never leave an accessibility node without text or content description Android: fix crash on a stale accessibility virtual view id, and default unnamed nodes to their type name Sep 1, 2026
@ronnycohen

Copy link
Copy Markdown
Contributor Author

You're right, and it is the better fix: a space only satisfies the platform contract, it describes
nothing, and it hides an unnamed control instead of surfacing it. The fallback is available —
GetClassNameCore() is abstract and ControlAutomationPeer returns Owner.GetType().Name, so
every peer has a type name; the ?? string.Empty in GetClassName() never fires in practice.
Pushed, and it is the same value nodeInfo.ClassName already carries.

While checking that, I have to correct my own PR description. I claimed the guard used
TextUtils.isEmpty, so that "" would not satisfy it. It does not. In androidx.customview 1.1.0
and 1.2.0, createNodeForChild throws only when getText() == null && getContentDescription() == null — a strict null check — and the class contains no TextUtils reference at all. The isEmpty
I was thinking of is event.getText().isEmpty(), on the event path, which throws a different
message (populateEventForVirtualViewId()).

That changes the attribution. Since GetName() collapses a missing name to string.Empty rather
than null, a node built for an unnamed container was always accepted — coherence check: were it
otherwise, every Avalonia Android app would go down on the first container the platform explores.
The crash comes only from the stale virtual view id, which #22024 made reachable by unregistering
peers on detach. So the type-name default is an accessibility improvement, not a crash fix, and the
description now says so.

One question before this goes further: should that default live in the Android backend, or in
AutomationPeer so every backend behaves the same? As it stands, Android will announce type names
where Windows and macOS stay silent — and since nodeInfo.ClassName already carries the type name,
a screen reader may end up announcing it twice. Happy to split the two changes into separate PRs if
you'd rather review them apart.

The rationale belongs in the pull request, not in the source. Only the two lines a
reader cannot infer from the code are kept.
@ronnycohen

Copy link
Copy Markdown
Contributor Author

Applied your two points from #22123 here as well: description rewritten against the PR template, and the explanatory comments cut down to the two lines a reader cannot infer from the code. The rest of the rationale is in the description.

The design question from my previous comment still stands whenever you get to it — Android backend or AutomationPeer?

@MrJul

MrJul commented Sep 1, 2026

Copy link
Copy Markdown
Member

Any further comments need to come from a human. We're able to interact with agents by ourselves, thank you.

@ronnycohen

Copy link
Copy Markdown
Contributor Author

Any further comments need to come from a human. We're able to interact with agents by ourselves, thank you.

Got it, sorry. Next time I'll write them myself.

@MrJul

MrJul commented Sep 1, 2026

Copy link
Copy Markdown
Member

appy to split them if you would rather review them apart.

Yes please, let's have the fix now and discuss the name change elsewhere.

Got it, sorry. Next time I'll write them myself.

Thank you

Keeps this PR to the crash fix alone. The default label for an unnamed node is a
separate discussion.
@ronnycohen ronnycohen changed the title Android: fix crash on a stale accessibility virtual view id, and default unnamed nodes to their type name Android: fix crash on a stale accessibility virtual view id Sep 1, 2026
@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.2.999-cibuild0069292-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@MrJul MrJul added bug os-android backport-candidate-12.1.x Consider this PR for backporting to 12.1 branch area-accessibility labels Sep 2, 2026

@MrJul MrJul left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@MrJul
MrJul added this pull request to the merge queue Sep 2, 2026
Merged via the queue into AvaloniaUI:main with commit 774f561 Sep 2, 2026
11 checks passed
MrJul pushed a commit to MrJul/Avalonia that referenced this pull request Sep 2, 2026
…UI#22122)

* Android: never leave an accessibility node without text or content description

`ExploreByTouchHelper.createNodeForChild` validates every virtual view the
callback produces and throws

    Callbacks must add text or a content description in populateNodeForVirtualViewId()

when the node carries neither. It throws from inside an accessibility callback,
so the exception is not recoverable by the application: the process goes down.

`TextUtils.isEmpty` treats `""` as empty, so an empty string does not satisfy
the contract - only a non-empty value does. Two paths in
`OnPopulateNodeForVirtualView` could produce such a node:

* **A live peer with no accessible name.** `nodeInfo.Text ??= peer.GetName()`
  looks like it only assigns when nothing was set, but `AutomationPeer.GetName()`
  and `GetHelpText()` never return null - they collapse a missing value to
  `string.Empty` - so both assignments always run and can both assign `""`.
  This happens for any peer that is a pure container: a `Panel`, a `Border`, or
  the `TextSelectorLayer` that is added when a text selection starts. On a device
  with an accessibility service running, the first touch on a text field was
  enough to bring the application down.

* **A stale virtual view id.** Since AvaloniaUI#22024 peers are unregistered when their
  control leaves the visual tree, and the platform can still ask for a node it
  obtained earlier - it caches them, and it re-queries the accessibility focused
  one. The lookup then fails and the node was left untouched, which the same
  validation rejects.

Both are fixed by guaranteeing a non-empty content description. A single space
is used deliberately: it satisfies the platform contract without inventing a
label that screen readers would announce.

Note that `AutomationPeer.GetClassName()` has the same `?? string.Empty` shape,
so it cannot serve as the fallback.

### Testing

`Avalonia.Android` builds clean. The crash paths need a device with an
accessibility service enabled and are not reachable from the unit test projects;
the fix was verified on hardware (Android 13 kiosk) where the first touch on a
text field used to take the application down on every armed process.

* Android: default an unlabelled node to its type name, and correct the attribution

Review feedback: a single space only satisfies the platform contract, it does not
describe anything, and it hides an unnamed control instead of surfacing it. The
fallback is available - GetClassNameCore() is abstract and ControlAutomationPeer
returns Owner.GetType().Name - so a peer always has a type name, which is also what
nodeInfo.ClassName already carries.

This also corrects a claim made in the first revision of this change. The androidx
guard does not use TextUtils.isEmpty: in androidx.customview 1.1.0 and 1.2.0,
createNodeForChild tests getText() and getContentDescription() for null, strictly,
and the class contains no TextUtils reference at all (the isEmpty guard sits on the
event path, which throws populateEventForVirtualViewId()).

Since AutomationPeer.GetName() collapses a missing name to string.Empty, a node
built for an unnamed container was therefore never rejected. Only the stale virtual
view id path - reachable since AvaloniaUI#22024 unregisters peers on detach - produced a node
the platform refuses. The placeholder there is now string.Empty, and the type name
default is presented for what it is: an accessibility improvement, not a crash fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Cut the explanatory comments down, per review on AvaloniaUI#22123

The rationale belongs in the pull request, not in the source. Only the two lines a
reader cannot infer from the code are kept.

* Drop the type name default, as requested in review

Keeps this PR to the crash fix alone. The default label for an unnamed node is a
separate discussion.

---------

Co-authored-by: ronnycohen <19652995+ronnycohen@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@MrJul MrJul added backported-12.1.x and removed backport-candidate-12.1.x Consider this PR for backporting to 12.1 branch labels Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants