From 48665c06a08e999782c703e690dc7337514acc97 Mon Sep 17 00:00:00 2001 From: ronnycohen <19652995+ronnycohen@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:30:37 +0200 Subject: [PATCH 1/2] Android: never walk an InteropAutomationPeer during accessibility traversal InteropAutomationPeer - the peer of a native interop control - throws NotImplementedException from almost every member (GetOrCreateChildrenCore, GetNameCore, GetBoundingRectangleCore, IsEnabledCore...), because it is meant to be special-cased by each platform backend. macOS does so through AvnAutomationPeer.IsInteropPeer, Windows through AutomationNode.InteropAutomationNode. Android did not, and AvaloniaAccessHelper called those members without any guard. Any accessibility traversal reaching a screen that hosts a native control - a NativeControlHost, so a WebView or an embedded media surface - therefore threw from inside an accessibility callback, which is not recoverable: the process goes down. It is reproducible in one command on such a screen: adb shell uiautomator dump Reported by any accessibility client walking the tree, so a screen reader or an MDM agent would take the same path. Interop peers are now filtered at every point where a virtual view ID could be handed out: the point hit test, the visible-views enumeration, the focused peer, and the children walk. A virtual view ID is therefore never allocated for one, and OnPopulateNodeForVirtualView never has to deal with one - which matters, because it may not answer with an unpopulated node: ExploreByTouchHelper.createNodeForChild rejects that and throws too. Skipping is the correct behaviour here rather than merely the safe one: a native control is a real Android View, already exposed to the accessibility framework on its own. Presenting it a second time as an Avalonia virtual view would duplicate it. InteropAutomationPeer is internal, hence the InternalsVisibleTo entry - Avalonia.Native and Avalonia.Win32.Automation already have one for the same reason. Co-Authored-By: Claude Opus 5 --- .../Avalonia.Android/AvaloniaAccessHelper.cs | 41 ++++++++++++++++++- .../Avalonia.Controls.csproj | 1 + 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs b/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs index 0bec287fedd..d8b20b89233 100644 --- a/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs +++ b/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs @@ -8,6 +8,7 @@ using Avalonia.Automation; using Avalonia.Automation.Peers; using Avalonia.Automation.Provider; +using Avalonia.Controls.Automation.Peers; using Java.Lang; namespace Avalonia.Android @@ -84,6 +85,28 @@ public AvaloniaAccessHelper(AvaloniaView view) : base(view) } } + /// + /// Peers of native interop controls () must + /// never be walked: InteropAutomationPeer throws + /// from almost every member, because it is meant + /// to be special-cased by each platform backend. The other backends already do so + /// (AvnAutomationPeer.IsInteropPeer, AutomationNode.InteropAutomationNode); + /// Android did not, so any accessibility traversal reaching a native control threw from + /// inside an accessibility callback and took the application down. + /// + /// Skipping is the correct behaviour here, not merely the safe one: a native control is a + /// real Android View, already exposed to the accessibility framework on its own. + /// Presenting it a second time as an Avalonia virtual view would duplicate it. + /// + /// + /// + /// Interop peers are filtered at every point where a virtual view ID could be handed out, so + /// that one is never allocated for them. OnPopulateNodeForVirtualView therefore never + /// has to deal with one - which matters, because it may not answer with an unpopulated node: + /// ExploreByTouchHelper.createNodeForChild rejects that and throws. + /// + private static bool IsInteropPeer(AutomationPeer peer) => peer is InteropAutomationPeer; + private HashSet GetOrCreateNodeInfoProvidersFromPeer(AutomationPeer peer, out int virtualViewId) { int peerViewId; @@ -175,6 +198,12 @@ protected override int GetVirtualViewAt(float x, float y) AutomationPeer? peer = embeddedRootProvider?.GetPeerFromPoint(p); if (peer is not null) { + if (IsInteropPeer(peer)) + { + // The point lands on a native control, which Android already exposes itself. + return InvalidId; + } + int virtualViewId; if (peer.GetParent() is AutomationPeer parent && !s_containerTypes.Contains(parent.GetAutomationControlType())) @@ -191,7 +220,7 @@ protected override int GetVirtualViewAt(float x, float y) else { peer = embeddedRootProvider?.GetFocus(); - return peer is null ? InvalidId : _peerIds[peer]; + return peer is null || IsInteropPeer(peer) ? InvalidId : _peerIds[peer]; } } @@ -204,6 +233,11 @@ protected override void GetVisibleVirtualViews(IList? virtualViewIds) foreach (AutomationPeer peer in _peers[0].GetChildren()) { + if (IsInteropPeer(peer)) + { + continue; + } + GetOrCreateNodeInfoProvidersFromPeer(peer, out int virtualViewId); virtualViewIds.Add(Integer.ValueOf(virtualViewId)); } @@ -255,6 +289,11 @@ protected override void OnPopulateNodeForVirtualView(int virtualViewId, Accessib // UI logical structure foreach (AutomationPeer child in peer.GetChildren()) { + if (IsInteropPeer(child)) + { + continue; + } + GetOrCreateNodeInfoProvidersFromPeer(child, out int childId); nodeInfo.AddChild(_view, childId); } diff --git a/src/Avalonia.Controls/Avalonia.Controls.csproj b/src/Avalonia.Controls/Avalonia.Controls.csproj index 2a67934a983..2bbaaf60ec2 100644 --- a/src/Avalonia.Controls/Avalonia.Controls.csproj +++ b/src/Avalonia.Controls/Avalonia.Controls.csproj @@ -22,6 +22,7 @@ + From be14bea5b9b0ebcbd350be626f0029bbab3e0be8 Mon Sep 17 00:00:00 2001 From: ronnycohen <19652995+ronnycohen@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:46:58 +0200 Subject: [PATCH 2/2] Drop the explanatory comments, per review The rationale belongs in the pull request, not in the source. --- .../Avalonia.Android/AvaloniaAccessHelper.cs | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs b/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs index d8b20b89233..59bd7864cd9 100644 --- a/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs +++ b/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs @@ -85,26 +85,6 @@ public AvaloniaAccessHelper(AvaloniaView view) : base(view) } } - /// - /// Peers of native interop controls () must - /// never be walked: InteropAutomationPeer throws - /// from almost every member, because it is meant - /// to be special-cased by each platform backend. The other backends already do so - /// (AvnAutomationPeer.IsInteropPeer, AutomationNode.InteropAutomationNode); - /// Android did not, so any accessibility traversal reaching a native control threw from - /// inside an accessibility callback and took the application down. - /// - /// Skipping is the correct behaviour here, not merely the safe one: a native control is a - /// real Android View, already exposed to the accessibility framework on its own. - /// Presenting it a second time as an Avalonia virtual view would duplicate it. - /// - /// - /// - /// Interop peers are filtered at every point where a virtual view ID could be handed out, so - /// that one is never allocated for them. OnPopulateNodeForVirtualView therefore never - /// has to deal with one - which matters, because it may not answer with an unpopulated node: - /// ExploreByTouchHelper.createNodeForChild rejects that and throws. - /// private static bool IsInteropPeer(AutomationPeer peer) => peer is InteropAutomationPeer; private HashSet GetOrCreateNodeInfoProvidersFromPeer(AutomationPeer peer, out int virtualViewId) @@ -200,7 +180,6 @@ protected override int GetVirtualViewAt(float x, float y) { if (IsInteropPeer(peer)) { - // The point lands on a native control, which Android already exposes itself. return InvalidId; }