Skip to content

Commit

Permalink
Sets focus before sending a11y focus event in Android (flutter#27992)
Browse files Browse the repository at this point in the history
  • Loading branch information
chunhtai authored Aug 13, 2021
1 parent d856fc3 commit 56cf819
Show file tree
Hide file tree
Showing 2 changed files with 129 additions and 6 deletions.
17 changes: 11 additions & 6 deletions shell/platform/android/io/flutter/view/AccessibilityBridge.java
Original file line number Diff line number Diff line change
Expand Up @@ -1020,28 +1020,33 @@ public boolean performAction(
}
case AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS:
{
// Focused semantics node must be reset before sending the
// TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED event. Otherwise,
// TalkBack may think the node is still focused.
accessibilityFocusedSemanticsNode = null;
embeddedAccessibilityFocusedNodeId = null;
accessibilityChannel.dispatchSemanticsAction(
virtualViewId, Action.DID_LOSE_ACCESSIBILITY_FOCUS);
sendAccessibilityEvent(
virtualViewId, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED);
accessibilityFocusedSemanticsNode = null;
embeddedAccessibilityFocusedNodeId = null;
return true;
}
case AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS:
{
accessibilityChannel.dispatchSemanticsAction(
virtualViewId, Action.DID_GAIN_ACCESSIBILITY_FOCUS);
sendAccessibilityEvent(virtualViewId, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED);

if (accessibilityFocusedSemanticsNode == null) {
// When Android focuses a node, it doesn't invalidate the view.
// (It does when it sends ACTION_CLEAR_ACCESSIBILITY_FOCUS, so
// we only have to worry about this when the focused node is null.)
rootAccessibilityView.invalidate();
}
// Focused semantics node must be set before sending the TYPE_VIEW_ACCESSIBILITY_FOCUSED
// event. Otherwise, TalkBack may think the node is not focused yet.
accessibilityFocusedSemanticsNode = semanticsNode;

accessibilityChannel.dispatchSemanticsAction(
virtualViewId, Action.DID_GAIN_ACCESSIBILITY_FOCUS);
sendAccessibilityEvent(virtualViewId, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED);

if (semanticsNode.hasAction(Action.INCREASE)
|| semanticsNode.hasAction(Action.DECREASE)) {
// SeekBars only announce themselves after this event.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package io.flutter.view;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.Matchers.eq;
Expand Down Expand Up @@ -46,6 +47,7 @@
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.invocation.InvocationOnMock;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.annotation.Config;
Expand Down Expand Up @@ -798,6 +800,122 @@ public void itCanPredictSetSelection() {
assertEquals(nodeInfo.getTextSelectionEnd(), expectedEnd);
}

@Test
public void itSetsFocusedNodeBeforeSendingEvent() {
AccessibilityChannel mockChannel = mock(AccessibilityChannel.class);
AccessibilityViewEmbedder mockViewEmbedder = mock(AccessibilityViewEmbedder.class);
AccessibilityManager mockManager = mock(AccessibilityManager.class);
View mockRootView = mock(View.class);
Context context = mock(Context.class);
when(mockRootView.getContext()).thenReturn(context);
when(context.getPackageName()).thenReturn("test");
AccessibilityBridge accessibilityBridge =
setUpBridge(
/*rootAccessibilityView=*/ mockRootView,
/*accessibilityChannel=*/ mockChannel,
/*accessibilityManager=*/ mockManager,
/*contentResolver=*/ null,
/*accessibilityViewEmbedder=*/ mockViewEmbedder,
/*platformViewsAccessibilityDelegate=*/ null);

ViewParent mockParent = mock(ViewParent.class);
when(mockRootView.getParent()).thenReturn(mockParent);
when(mockManager.isEnabled()).thenReturn(true);

TestSemanticsNode root = new TestSemanticsNode();
root.id = 0;
root.label = "root";

TestSemanticsUpdate testSemanticsUpdate = root.toUpdate();
testSemanticsUpdate.sendUpdateToBridge(accessibilityBridge);

class Verifier {
public Verifier(AccessibilityBridge accessibilityBridge) {
this.accessibilityBridge = accessibilityBridge;
}

public AccessibilityBridge accessibilityBridge;
public boolean verified = false;

public boolean verify(InvocationOnMock invocation) {
AccessibilityEvent event = (AccessibilityEvent) invocation.getArguments()[1];
assertEquals(event.getEventType(), AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED);
// The accessibility focus must be set before sending out
// the TYPE_VIEW_ACCESSIBILITY_FOCUSED event.
AccessibilityNodeInfo nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(0);
assertTrue(nodeInfo.isAccessibilityFocused());
verified = true;
return true;
}
};
Verifier verifier = new Verifier(accessibilityBridge);
when(mockParent.requestSendAccessibilityEvent(eq(mockRootView), any(AccessibilityEvent.class)))
.thenAnswer(invocation -> verifier.verify(invocation));
accessibilityBridge.performAction(0, AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, null);
assertTrue(verifier.verified);
}

@Test
public void itClearsFocusedNodeBeforeSendingEvent() {
AccessibilityChannel mockChannel = mock(AccessibilityChannel.class);
AccessibilityViewEmbedder mockViewEmbedder = mock(AccessibilityViewEmbedder.class);
AccessibilityManager mockManager = mock(AccessibilityManager.class);
View mockRootView = mock(View.class);
Context context = mock(Context.class);
when(mockRootView.getContext()).thenReturn(context);
when(context.getPackageName()).thenReturn("test");
AccessibilityBridge accessibilityBridge =
setUpBridge(
/*rootAccessibilityView=*/ mockRootView,
/*accessibilityChannel=*/ mockChannel,
/*accessibilityManager=*/ mockManager,
/*contentResolver=*/ null,
/*accessibilityViewEmbedder=*/ mockViewEmbedder,
/*platformViewsAccessibilityDelegate=*/ null);

ViewParent mockParent = mock(ViewParent.class);
when(mockRootView.getParent()).thenReturn(mockParent);
when(mockManager.isEnabled()).thenReturn(true);

TestSemanticsNode root = new TestSemanticsNode();
root.id = 0;
root.label = "root";

TestSemanticsUpdate testSemanticsUpdate = root.toUpdate();
testSemanticsUpdate.sendUpdateToBridge(accessibilityBridge);
// Set the focus on root.
accessibilityBridge.performAction(0, AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, null);
AccessibilityNodeInfo nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(0);
assertTrue(nodeInfo.isAccessibilityFocused());

class Verifier {
public Verifier(AccessibilityBridge accessibilityBridge) {
this.accessibilityBridge = accessibilityBridge;
}

public AccessibilityBridge accessibilityBridge;
public boolean verified = false;

public boolean verify(InvocationOnMock invocation) {
AccessibilityEvent event = (AccessibilityEvent) invocation.getArguments()[1];
assertEquals(
event.getEventType(), AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED);
// The accessibility focus must be cleared before sending out
// the TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED event.
AccessibilityNodeInfo nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(0);
assertFalse(nodeInfo.isAccessibilityFocused());
verified = true;
return true;
}
};
Verifier verifier = new Verifier(accessibilityBridge);
when(mockParent.requestSendAccessibilityEvent(eq(mockRootView), any(AccessibilityEvent.class)))
.thenAnswer(invocation -> verifier.verify(invocation));
accessibilityBridge.performAction(
0, AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS, null);
assertTrue(verifier.verified);
}

@Test
public void itCanPredictCursorMovementsWithGranularityWord() {
AccessibilityChannel mockChannel = mock(AccessibilityChannel.class);
Expand Down

0 comments on commit 56cf819

Please sign in to comment.