Skip to content

Commit

Permalink
Implement support for Android TalkBack (#17704)
Browse files Browse the repository at this point in the history
* Start working on Android explore by touch

* Start working on a more serious solution

* Reflectionless approach

* Allow multiple providers to be defined for the same AutomationPeer instance

* Implement EmbeddableControlRootAutomationPeer

* Garbage collection

* It's working!!

* Get better readouts

* Implement rest of providers and improve performance

* Some cleanup for the PR!

* Whoopsie!

* Better text readouts for more descriptive elements

* Fix bug with previous approach

* Some final tweaks

* Last tweak!

* Slight improvements

* Undo last change

* Fix bug where custom provider types would not be registered

* Better TextBox compatibility with screen readers & TalkBack

* Fix regression for LabeledBy tests

* Clean up provider code

* Final batch of fixes for TextBox behavior

* Append text instead of replacing it to fix buggy screen readers

* Even more fixes for buggy screen readers

* Remove english-specific state descriptions

* Code review improvements

---------

Co-authored-by: Jumar Macato <16554748+jmacato@users.noreply.github.com>
  • Loading branch information
IsaMorphic and jmacato authored Jan 20, 2025
1 parent 19394e0 commit 57c8595
Show file tree
Hide file tree
Showing 15 changed files with 716 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Android.OS;
using AndroidX.Core.View.Accessibility;
using AndroidX.CustomView.Widget;
using Avalonia.Automation.Peers;
using Avalonia.Automation.Provider;

namespace Avalonia.Android.Automation
{
internal class ExpandCollapseNodeInfoProvider : NodeInfoProvider<IExpandCollapseProvider>
{
public ExpandCollapseNodeInfoProvider(ExploreByTouchHelper owner, AutomationPeer peer, int virtualViewId) :
base(owner, peer, virtualViewId)
{
}

public override bool PerformNodeAction(int action, Bundle? arguments)
{
IExpandCollapseProvider provider = GetProvider();
switch (action)
{
case AccessibilityNodeInfoCompat.ActionExpand:
provider.Expand();
return true;
case AccessibilityNodeInfoCompat.ActionCollapse:
provider.Collapse();
return true;
default:
return false;
}
}

public override void PopulateNodeInfo(AccessibilityNodeInfoCompat nodeInfo)
{
nodeInfo.AddAction(AccessibilityNodeInfoCompat.ActionExpand);
nodeInfo.AddAction(AccessibilityNodeInfoCompat.ActionCollapse);
}
}
}
14 changes: 14 additions & 0 deletions src/Android/Avalonia.Android/Automation/INodeInfoProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Android.OS;
using AndroidX.Core.View.Accessibility;

namespace Avalonia.Android.Automation
{
public interface INodeInfoProvider
{
int VirtualViewId { get; }

bool PerformNodeAction(int action, Bundle? arguments);

void PopulateNodeInfo(AccessibilityNodeInfoCompat nodeInfo);
}
}
35 changes: 35 additions & 0 deletions src/Android/Avalonia.Android/Automation/InvokeNodeInfoProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using Android.OS;
using AndroidX.Core.View.Accessibility;
using AndroidX.CustomView.Widget;
using Avalonia.Automation.Peers;
using Avalonia.Automation.Provider;

namespace Avalonia.Android.Automation
{
internal class InvokeNodeInfoProvider : NodeInfoProvider<IInvokeProvider>
{
public InvokeNodeInfoProvider(ExploreByTouchHelper owner, AutomationPeer peer, int virtualViewId) :
base(owner, peer, virtualViewId)
{
}

public override bool PerformNodeAction(int action, Bundle? arguments)
{
IInvokeProvider provider = GetProvider();
switch (action)
{
case AccessibilityNodeInfoCompat.ActionClick:
provider.Invoke();
return true;
default:
return false;
}
}

public override void PopulateNodeInfo(AccessibilityNodeInfoCompat nodeInfo)
{
nodeInfo.AddAction(AccessibilityNodeInfoCompat.ActionClick);
nodeInfo.Clickable = true;
}
}
}
48 changes: 48 additions & 0 deletions src/Android/Avalonia.Android/Automation/NodeInfoProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System;
using Android.OS;
using AndroidX.Core.View.Accessibility;
using AndroidX.CustomView.Widget;
using Avalonia.Automation;
using Avalonia.Automation.Peers;

namespace Avalonia.Android.Automation
{
internal delegate INodeInfoProvider NodeInfoProviderInitializer(ExploreByTouchHelper owner, AutomationPeer peer, int virtualViewId);

internal abstract class NodeInfoProvider<T> : INodeInfoProvider
{
private readonly ExploreByTouchHelper _owner;

private readonly AutomationPeer _peer;

public int VirtualViewId { get; }

public NodeInfoProvider(ExploreByTouchHelper owner, AutomationPeer peer, int virtualViewId)
{
_owner = owner;
_peer = peer;
VirtualViewId = virtualViewId;

_peer.PropertyChanged += PeerPropertyChanged;
}

protected void InvalidateSelf()
{
_owner.InvalidateVirtualView(VirtualViewId);
}

protected void InvalidateSelf(int changeTypes)
{
_owner.InvalidateVirtualView(VirtualViewId, changeTypes);
}

protected virtual void PeerPropertyChanged(object? sender, AutomationPropertyChangedEventArgs e) { }

public T GetProvider() => _peer.GetProvider<T>() ??
throw new InvalidOperationException($"Peer instance does not implement {nameof(T)}.");

public abstract bool PerformNodeAction(int action, Bundle? arguments);

public abstract void PopulateNodeInfo(AccessibilityNodeInfoCompat nodeInfo);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using Android.OS;
using AndroidX.Core.View.Accessibility;
using AndroidX.CustomView.Widget;
using Avalonia.Automation.Peers;
using Avalonia.Automation.Provider;

namespace Avalonia.Android.Automation
{
internal class RangeValueNodeInfoProvider : NodeInfoProvider<IRangeValueProvider>
{
public RangeValueNodeInfoProvider(ExploreByTouchHelper owner, AutomationPeer peer, int virtualViewId) :
base(owner, peer, virtualViewId)
{
}

public override bool PerformNodeAction(int action, Bundle? arguments)
{
return false;
}

public override void PopulateNodeInfo(AccessibilityNodeInfoCompat nodeInfo)
{
IRangeValueProvider provider = GetProvider();
nodeInfo.RangeInfo = new AccessibilityNodeInfoCompat.RangeInfoCompat(
AccessibilityNodeInfoCompat.RangeInfoCompat.RangeTypeFloat,
(float)provider.Minimum, (float)provider.Maximum,
(float)provider.Value
);
}
}
}
53 changes: 53 additions & 0 deletions src/Android/Avalonia.Android/Automation/ScrollNodeInfoProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using Android.OS;
using AndroidX.Core.View.Accessibility;
using AndroidX.CustomView.Widget;
using Avalonia.Automation.Peers;
using Avalonia.Automation.Provider;

namespace Avalonia.Android.Automation
{
internal class ScrollNodeInfoProvider : NodeInfoProvider<IScrollProvider>
{
public ScrollNodeInfoProvider(ExploreByTouchHelper owner, AutomationPeer peer, int virtualViewId) :
base(owner, peer, virtualViewId)
{
}

public override bool PerformNodeAction(int action, Bundle? arguments)
{
IScrollProvider provider = GetProvider();
switch (action)
{
case AccessibilityNodeInfoCompat.ActionScrollForward:
if (provider.VerticallyScrollable)
{
provider.Scroll(ScrollAmount.NoAmount, ScrollAmount.SmallIncrement);
}
else if(provider.HorizontallyScrollable)
{
provider.Scroll(ScrollAmount.SmallIncrement, ScrollAmount.NoAmount);
}
return true;
case AccessibilityNodeInfoCompat.ActionScrollBackward:
if (provider.VerticallyScrollable)
{
provider.Scroll(ScrollAmount.NoAmount, ScrollAmount.SmallDecrement);
}
else if (provider.HorizontallyScrollable)
{
provider.Scroll(ScrollAmount.SmallDecrement, ScrollAmount.NoAmount);
}
return true;
default:
return false;
}
}

public override void PopulateNodeInfo(AccessibilityNodeInfoCompat nodeInfo)
{
nodeInfo.AddAction(AccessibilityNodeInfoCompat.ActionScrollForward);
nodeInfo.AddAction(AccessibilityNodeInfoCompat.ActionScrollBackward);
nodeInfo.Scrollable = true;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using Android.OS;
using AndroidX.Core.View.Accessibility;
using AndroidX.CustomView.Widget;
using Avalonia.Automation.Peers;
using Avalonia.Automation.Provider;

namespace Avalonia.Android.Automation
{
internal class SelectionItemNodeInfoProvider : NodeInfoProvider<ISelectionItemProvider>
{
public SelectionItemNodeInfoProvider(ExploreByTouchHelper owner, AutomationPeer peer, int virtualViewId) :
base(owner, peer, virtualViewId)
{
}

public override bool PerformNodeAction(int action, Bundle? arguments)
{
ISelectionItemProvider provider = GetProvider();
switch (action)
{
case AccessibilityNodeInfoCompat.ActionSelect:
provider.Select();
return true;
default:
return false;
}
}

public override void PopulateNodeInfo(AccessibilityNodeInfoCompat nodeInfo)
{
nodeInfo.AddAction(AccessibilityNodeInfoCompat.ActionSelect);

ISelectionItemProvider provider = GetProvider();
nodeInfo.Selected = provider.IsSelected;
}
}
}
39 changes: 39 additions & 0 deletions src/Android/Avalonia.Android/Automation/ToggleNodeInfoProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using Android.OS;
using AndroidX.Core.View.Accessibility;
using AndroidX.CustomView.Widget;
using Avalonia.Automation.Peers;
using Avalonia.Automation.Provider;

namespace Avalonia.Android.Automation
{
internal class ToggleNodeInfoProvider : NodeInfoProvider<IToggleProvider>
{
public ToggleNodeInfoProvider(ExploreByTouchHelper owner, AutomationPeer peer, int virtualViewId) :
base(owner, peer, virtualViewId)
{
}

public override bool PerformNodeAction(int action, Bundle? arguments)
{
IToggleProvider provider = GetProvider();
switch (action)
{
case AccessibilityNodeInfoCompat.ActionClick:
provider.Toggle();
return true;
default:
return false;
}
}

public override void PopulateNodeInfo(AccessibilityNodeInfoCompat nodeInfo)
{
nodeInfo.AddAction(AccessibilityNodeInfoCompat.ActionClick);
nodeInfo.Clickable = true;

IToggleProvider provider = GetProvider();
nodeInfo.Checked = provider.ToggleState == ToggleState.On;
nodeInfo.Checkable = true;
}
}
}
59 changes: 59 additions & 0 deletions src/Android/Avalonia.Android/Automation/ValueNodeInfoProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using Android.OS;
using AndroidX.Core.View;
using AndroidX.Core.View.Accessibility;
using AndroidX.CustomView.Widget;
using Avalonia.Automation;
using Avalonia.Automation.Peers;
using Avalonia.Automation.Provider;

namespace Avalonia.Android.Automation
{
internal class ValueNodeInfoProvider : NodeInfoProvider<IValueProvider>
{
public ValueNodeInfoProvider(ExploreByTouchHelper owner, AutomationPeer peer, int virtualViewId) :
base(owner, peer, virtualViewId)
{
}

protected override void PeerPropertyChanged(object? sender, AutomationPropertyChangedEventArgs e)
{
base.PeerPropertyChanged(sender, e);
if (e.Property == ValuePatternIdentifiers.ValueProperty)
{
InvalidateSelf(AccessibilityEventCompat.ContentChangeTypeText);
}
}

public override bool PerformNodeAction(int action, Bundle? arguments)
{
IValueProvider provider = GetProvider();
switch (action)
{
case AccessibilityNodeInfoCompat.ActionSetText:
string? text = arguments?.GetCharSequence(
AccessibilityNodeInfoCompat.ActionArgumentSetTextCharsequence
);
provider.SetValue(provider.Value + text);
return true;

default:
return false;
}
}

public override void PopulateNodeInfo(AccessibilityNodeInfoCompat nodeInfo)
{
nodeInfo.AddAction(AccessibilityNodeInfoCompat.ActionSetText);

IValueProvider provider = GetProvider();
nodeInfo.Text = provider.Value;
nodeInfo.Editable = !provider.IsReadOnly;

nodeInfo.SetTextSelection(
provider.Value?.Length ?? 0,
provider.Value?.Length ?? 0
);
nodeInfo.LiveRegion = ViewCompat.AccessibilityLiveRegionPolite;
}
}
}
Loading

0 comments on commit 57c8595

Please sign in to comment.