From 94bb845632435b68e72c1a828066ac11d555e33d Mon Sep 17 00:00:00 2001 From: Chun-Heng Tai Date: Mon, 16 Aug 2021 11:33:43 -0700 Subject: [PATCH 1/3] Reland "Sets focus before sending a11y focus event in Android (#27992)" This reverts commit b1ccc4175c9e4980fcd7abe113e641800cd43dff. --- .../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); From 6091051bce55aaff41097f62e0df8b47268454b0 Mon Sep 17 00:00:00 2001 From: Chun-Heng Tai Date: Mon, 16 Aug 2021 14:04:22 -0700 Subject: [PATCH 2/3] fix issue --- .../io/flutter/view/AccessibilityBridge.java | 10 +++- .../flutter/view/AccessibilityBridgeTest.java | 48 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/shell/platform/android/io/flutter/view/AccessibilityBridge.java b/shell/platform/android/io/flutter/view/AccessibilityBridge.java index 6728cde166fec..b88301dabd686 100644 --- a/shell/platform/android/io/flutter/view/AccessibilityBridge.java +++ b/shell/platform/android/io/flutter/view/AccessibilityBridge.java @@ -1023,8 +1023,14 @@ public boolean performAction( // 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; + if (accessibilityFocusedSemanticsNode != null + && accessibilityFocusedSemanticsNode.id == virtualViewId) { + accessibilityFocusedSemanticsNode = null; + } + if (embeddedAccessibilityFocusedNodeId != null + && embeddedAccessibilityFocusedNodeId == virtualViewId) { + embeddedAccessibilityFocusedNodeId = null; + } accessibilityChannel.dispatchSemanticsAction( virtualViewId, Action.DID_LOSE_ACCESSIBILITY_FOCUS); sendAccessibilityEvent( diff --git a/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java b/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java index 1fc72c21fa5e5..18e1905cb0dfb 100644 --- a/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java +++ b/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java @@ -800,6 +800,54 @@ public void itCanPredictSetSelection() { assertEquals(nodeInfo.getTextSelectionEnd(), expectedEnd); } + @Test + public void itPerformsClearAccessibilityFocusCorrectly() { + 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"; + TestSemanticsNode node1 = new TestSemanticsNode(); + node1.id = 1; + node1.value = "some text"; + root.children.add(node1); + + TestSemanticsUpdate testSemanticsUpdate = root.toUpdate(); + testSemanticsUpdate.sendUpdateToBridge(accessibilityBridge); + accessibilityBridge.performAction(0, AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, null); + AccessibilityNodeInfo nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(1); + assertTrue(nodeInfo.isAccessibilityFocused()); + // Clear focus on non-focused node shouldn't do anything + accessibilityBridge.performAction( + 1, AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS, null); + nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(1); + assertTrue(nodeInfo.isAccessibilityFocused()); + + // Now, clear the focus for real. + accessibilityBridge.performAction( + 0, AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS, null); + nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(1); + assertFalse(nodeInfo.isAccessibilityFocused()); + } + @Test public void itSetsFocusedNodeBeforeSendingEvent() { AccessibilityChannel mockChannel = mock(AccessibilityChannel.class); From 9cc5aa49cfeb53202f04d28bf05e3ea1f61d4a2c Mon Sep 17 00:00:00 2001 From: Chun-Heng Tai Date: Mon, 16 Aug 2021 14:20:37 -0700 Subject: [PATCH 3/3] update --- .../test/io/flutter/view/AccessibilityBridgeTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java b/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java index 18e1905cb0dfb..49df2d82ea1a2 100644 --- a/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java +++ b/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java @@ -833,18 +833,18 @@ public void itPerformsClearAccessibilityFocusCorrectly() { TestSemanticsUpdate testSemanticsUpdate = root.toUpdate(); testSemanticsUpdate.sendUpdateToBridge(accessibilityBridge); accessibilityBridge.performAction(0, AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, null); - AccessibilityNodeInfo nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(1); + AccessibilityNodeInfo nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(0); assertTrue(nodeInfo.isAccessibilityFocused()); // Clear focus on non-focused node shouldn't do anything accessibilityBridge.performAction( 1, AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS, null); - nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(1); + nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(0); assertTrue(nodeInfo.isAccessibilityFocused()); // Now, clear the focus for real. accessibilityBridge.performAction( 0, AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS, null); - nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(1); + nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(0); assertFalse(nodeInfo.isAccessibilityFocused()); }