From 56cf8198a773d9d1c7b7febe7988252af852a229 Mon Sep 17 00:00:00 2001 From: chunhtai <47866232+chunhtai@users.noreply.github.com> Date: Thu, 12 Aug 2021 17:22:02 -0700 Subject: [PATCH] Sets focus before sending a11y focus event in Android (#27992) --- .../io/flutter/view/AccessibilityBridge.java | 17 ++- .../flutter/view/AccessibilityBridgeTest.java | 118 ++++++++++++++++++ 2 files changed, 129 insertions(+), 6 deletions(-) diff --git a/shell/platform/android/io/flutter/view/AccessibilityBridge.java b/shell/platform/android/io/flutter/view/AccessibilityBridge.java index 308e1de10be78..6728cde166fec 100644 --- a/shell/platform/android/io/flutter/view/AccessibilityBridge.java +++ b/shell/platform/android/io/flutter/view/AccessibilityBridge.java @@ -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. diff --git a/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java b/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java index 1bb45d8592a72..1fc72c21fa5e5 100644 --- a/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java +++ b/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java @@ -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; @@ -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; @@ -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);