diff --git a/shell/platform/android/io/flutter/view/AccessibilityBridge.java b/shell/platform/android/io/flutter/view/AccessibilityBridge.java index 227dbc8829dda..42a2857f18f4e 100644 --- a/shell/platform/android/io/flutter/view/AccessibilityBridge.java +++ b/shell/platform/android/io/flutter/view/AccessibilityBridge.java @@ -1208,7 +1208,23 @@ private boolean performCursorMoveAction( arguments.getBoolean(AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN); // The voice access expects the semantics node to update immediately. We update the semantics // node based on prediction. If the result is incorrect, it will be updated in the next frame. + final int previousTextSelectionBase = semanticsNode.textSelectionBase; + final int previousTextSelectionExtent = semanticsNode.textSelectionExtent; predictCursorMovement(semanticsNode, granularity, forward, extendSelection); + + if (previousTextSelectionBase != semanticsNode.textSelectionBase + || previousTextSelectionExtent != semanticsNode.textSelectionExtent) { + final String value = semanticsNode.value != null ? semanticsNode.value : ""; + final AccessibilityEvent selectionEvent = + obtainAccessibilityEvent( + semanticsNode.id, AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED); + selectionEvent.getText().add(value); + selectionEvent.setFromIndex(semanticsNode.textSelectionBase); + selectionEvent.setToIndex(semanticsNode.textSelectionExtent); + selectionEvent.setItemCount(value.length()); + sendAccessibilityEvent(selectionEvent); + } + switch (granularity) { case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER: { diff --git a/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java b/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java index 23490aadf0a04..9096d2408f7f9 100644 --- a/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java +++ b/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java @@ -6,6 +6,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.mockito.Matchers.eq; @@ -1106,6 +1107,107 @@ public void itCanPredictCursorMovementsWithGranularityWord() { assertEquals(nodeInfo.getTextSelectionEnd(), 5); } + @Test + public void itAlsoFireSelectionEventWhenPredictCursorMovements() { + 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; + TestSemanticsNode node1 = new TestSemanticsNode(); + node1.id = 1; + node1.value = "some text"; + node1.textSelectionBase = 0; + node1.textSelectionExtent = 0; + node1.addFlag(AccessibilityBridge.Flag.IS_TEXT_FIELD); + root.children.add(node1); + TestSemanticsUpdate testSemanticsUpdate = root.toUpdate(); + testSemanticsUpdate.sendUpdateToBridge(accessibilityBridge); + Bundle bundle = new Bundle(); + bundle.putInt( + AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT, + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER); + bundle.putBoolean(AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN, false); + accessibilityBridge.performAction( + 1, AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, bundle); + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(AccessibilityEvent.class); + verify(mockParent, times(2)) + .requestSendAccessibilityEvent(eq(mockRootView), eventCaptor.capture()); + AccessibilityEvent event = eventCaptor.getAllValues().get(1); + assertEquals(event.getEventType(), AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED); + assertEquals(event.getText().toString(), "[" + node1.value + "]"); + assertEquals(event.getFromIndex(), 1); + assertEquals(event.getToIndex(), 1); + assertEquals(event.getItemCount(), node1.value.length()); + } + + @Test + public void itDoesNotFireSelectionEventWhenPredictCursorMovementsDoesNotChangeSelection() { + 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; + TestSemanticsNode node1 = new TestSemanticsNode(); + node1.id = 1; + node1.value = "some text"; + node1.textSelectionBase = 0; + node1.textSelectionExtent = 0; + node1.addFlag(AccessibilityBridge.Flag.IS_TEXT_FIELD); + root.children.add(node1); + TestSemanticsUpdate testSemanticsUpdate = root.toUpdate(); + testSemanticsUpdate.sendUpdateToBridge(accessibilityBridge); + Bundle bundle = new Bundle(); + bundle.putInt( + AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT, + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER); + bundle.putBoolean(AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN, false); + accessibilityBridge.performAction( + 1, AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY, bundle); + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(AccessibilityEvent.class); + verify(mockParent, times(1)) + .requestSendAccessibilityEvent(eq(mockRootView), eventCaptor.capture()); + assertEquals(eventCaptor.getAllValues().size(), 1); + AccessibilityEvent event = eventCaptor.getAllValues().get(0); + assertNotEquals(event.getEventType(), AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED); + } + @Test public void itCanPredictCursorMovementsWithGranularityWordUnicode() { AccessibilityChannel mockChannel = mock(AccessibilityChannel.class);