diff --git a/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java b/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java index 5f6bbf0c606ef..51dc26cc94b53 100644 --- a/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java +++ b/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java @@ -210,6 +210,11 @@ public class AccessibilityBridge extends AccessibilityNodeProvider { // This is null when a node embedded by the AccessibilityViewEmbedder has the focus. @Nullable SemanticsNode accessibilityFocusedSemanticsNode; + @Nullable private SemanticsNode accessibilityFocusClearingNode; + + private final Runnable clearFocusClearingNodeRunnable = + () -> accessibilityFocusClearingNode = null; + // The virtual ID of the currently embedded node with accessibility focus. // // This is the ID of a node generated by the AccessibilityViewEmbedder if an embedded node is @@ -644,6 +649,8 @@ private static float[] getMatrix4FromBuffer(@NonNull ByteBuffer buffer, float[] */ public void release() { isReleased = true; + rootAccessibilityView.removeCallbacks(clearFocusClearingNodeRunnable); + accessibilityFocusClearingNode = null; platformViewsAccessibilityDelegate.detachAccessibilityBridge(); setOnAccessibilityChangeListener(null); accessibilityManager.removeAccessibilityStateChangeListener(accessibilityStateChangeListener); @@ -1052,6 +1059,7 @@ public boolean performAction( // TalkBack may think the node is still focused. if (accessibilityFocusedSemanticsNode != null && accessibilityFocusedSemanticsNode.id == virtualViewId) { + accessibilityFocusClearingNode = accessibilityFocusedSemanticsNode; accessibilityFocusedSemanticsNode = null; } if (embeddedAccessibilityFocusedNodeId != null @@ -1062,16 +1070,20 @@ public boolean performAction( virtualViewId, Action.DID_LOSE_ACCESSIBILITY_FOCUS); sendAccessibilityEvent( virtualViewId, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED); + rootAccessibilityView.removeCallbacks(clearFocusClearingNodeRunnable); + rootAccessibilityView.post(clearFocusClearingNodeRunnable); return true; } case AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS: { - if (accessibilityFocusedSemanticsNode == null) { + if (accessibilityFocusedSemanticsNode == null && accessibilityFocusClearingNode == 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(); } + accessibilityFocusClearingNode = null; + rootAccessibilityView.removeCallbacks(clearFocusClearingNodeRunnable); // 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; @@ -2008,6 +2020,8 @@ public void reset() { AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED); } accessibilityFocusedSemanticsNode = null; + rootAccessibilityView.removeCallbacks(clearFocusClearingNodeRunnable); + accessibilityFocusClearingNode = null; hoveredObject = null; sendWindowContentChangeEvent(0, AccessibilityEvent.CONTENT_CHANGE_TYPE_SUBTREE); } diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java index d94cd1af4bad6..b64350d5e38b4 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java @@ -16,6 +16,7 @@ import static org.mockito.Mockito.any; import static org.mockito.Mockito.anyInt; import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; @@ -3280,6 +3281,62 @@ public void itDoesNotSetHeadingWhenHeadingLevelIsZero() { assertFalse(nonHeadingInfo.isHeading()); } + @Test + public void itDoesNotRedundantlyInvalidateRootViewWhenFocusChanges() { + BasicMessageChannel mockChannel = mock(BasicMessageChannel.class); + AccessibilityChannel accessibilityChannel = + new AccessibilityChannel(mockChannel, mock(FlutterJNI.class)); + AccessibilityViewEmbedder mockViewEmbedder = mock(AccessibilityViewEmbedder.class); + AccessibilityManager mockManager = mock(AccessibilityManager.class); + when(mockManager.isEnabled()).thenReturn(true); + 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= */ accessibilityChannel, + /* accessibilityManager= */ mockManager, + /* contentResolver= */ null, + /* accessibilityViewEmbedder= */ mockViewEmbedder, + /* platformViewsAccessibilityDelegate= */ null); + + ViewParent mockParent = mock(ViewParent.class); + when(mockRootView.getParent()).thenReturn(mockParent); + + // Create two semantics nodes + TestSemanticsNode root = new TestSemanticsNode(); + root.id = 0; + root.label = "root"; + TestSemanticsNode node1 = new TestSemanticsNode(); + node1.id = 1; + node1.value = "node1"; + root.children.add(node1); + TestSemanticsNode node2 = new TestSemanticsNode(); + node2.id = 2; + node2.value = "node2"; + root.children.add(node2); + + TestSemanticsUpdate testSemanticsUpdate = root.toUpdate(); + testSemanticsUpdate.sendUpdateToBridge(accessibilityBridge); + + // 1. Initial focus on node1 (nothing was focused before). + // This should trigger invalidate() on rootAccessibilityView. + accessibilityBridge.performAction(1, AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, null); + verify(mockRootView, times(1)).invalidate(); + + // 2. Clear focus on node1, and then focus node2 (focus shifting). + // This should NOT trigger invalidate() on rootAccessibilityView because Android + // invalidates the view when focus is cleared, making it redundant to invalidate again when + // focusing. + clearInvocations(mockRootView); + accessibilityBridge.performAction( + 1, AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS, null); + accessibilityBridge.performAction(2, AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, null); + verify(mockRootView, never()).invalidate(); + } + AccessibilityBridge setUpBridge() { return setUpBridge(null, null, null, null, null, null); }