From 7d6b2c863d3247f1ec61662aee6ef2ca9b6d5124 Mon Sep 17 00:00:00 2001 From: chunhtai <47866232+chunhtai@users.noreply.github.com> Date: Tue, 30 Mar 2021 10:21:21 -0700 Subject: [PATCH] Reland "Fixes android voice access delete text, redo, and undo action" (#25289) * Reland "Fixes android voice access delete text, redo, and undo actions. (#25050)" This reverts commit 4c6abc1e063e406970bd260d4343bfceb0e7b028. * fix condition --- .../io/flutter/view/AccessibilityBridge.java | 101 ++++- .../flutter/view/AccessibilityBridgeTest.java | 356 ++++++++++++++++++ 2 files changed, 453 insertions(+), 4 deletions(-) diff --git a/shell/platform/android/io/flutter/view/AccessibilityBridge.java b/shell/platform/android/io/flutter/view/AccessibilityBridge.java index 3c9bbeab5..6ca683ec5 100644 --- a/shell/platform/android/io/flutter/view/AccessibilityBridge.java +++ b/shell/platform/android/io/flutter/view/AccessibilityBridge.java @@ -34,6 +34,8 @@ import io.flutter.util.Predicate; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Bridge between Android's OS accessibility system and Flutter's accessibility system. @@ -633,8 +635,7 @@ public class AccessibilityBridge extends AccessibilityNodeProvider { } // These are non-ops on older devices. Attempting to interact with the text will cause Talkback - // to read the - // contents of the text box instead. + // to read the contents of the text box instead. if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2) { if (semanticsNode.hasAction(Action.SET_SELECTION)) { result.addAction(AccessibilityNodeInfo.ACTION_SET_SELECTION); @@ -650,6 +651,13 @@ public class AccessibilityBridge extends AccessibilityNodeProvider { } } + // Set text API isn't available until API 21. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (semanticsNode.hasAction(Action.SET_TEXT)) { + result.addAction(AccessibilityNodeInfo.ACTION_SET_TEXT); + } + } + if (semanticsNode.hasFlag(Flag.IS_BUTTON) || semanticsNode.hasFlag(Flag.IS_LINK)) { result.setClassName("android.widget.Button"); } @@ -1034,6 +1042,12 @@ public class AccessibilityBridge extends AccessibilityNodeProvider { } accessibilityChannel.dispatchSemanticsAction( virtualViewId, Action.SET_SELECTION, selection); + // 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. + SemanticsNode node = flutterSemanticsTree.get(virtualViewId); + node.textSelectionBase = selection.get("base"); + node.textSelectionExtent = selection.get("extent"); return true; } case AccessibilityNodeInfo.ACTION_COPY: @@ -1064,7 +1078,7 @@ public class AccessibilityBridge extends AccessibilityNodeProvider { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { return false; } - return performSetText(virtualViewId, arguments); + return performSetText(semanticsNode, virtualViewId, arguments); } default: // might be a custom accessibility accessibilityAction. @@ -1094,6 +1108,9 @@ public class AccessibilityBridge extends AccessibilityNodeProvider { arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT); final boolean extendSelection = 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. + predictCursorMovement(semanticsNode, granularity, forward, extendSelection); switch (granularity) { case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER: { @@ -1121,23 +1138,99 @@ public class AccessibilityBridge extends AccessibilityNodeProvider { return true; } break; + case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE: + case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH: + case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PAGE: + return true; } return false; } + private void predictCursorMovement( + @NonNull SemanticsNode node, int granularity, boolean forward, boolean extendSelection) { + if (node.textSelectionExtent < 0 || node.textSelectionBase < 0) { + return; + } + + switch (granularity) { + case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER: + if (forward && node.textSelectionExtent < node.value.length()) { + node.textSelectionExtent += 1; + } else if (!forward && node.textSelectionExtent > 0) { + node.textSelectionExtent -= 1; + } + break; + case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD: + if (forward && node.textSelectionExtent < node.value.length()) { + Pattern pattern = Pattern.compile("\\p{L}(\\b)"); + Matcher result = pattern.matcher(node.value.substring(node.textSelectionExtent)); + // we discard the first result because we want to find the "next" word + result.find(); + if (result.find()) { + node.textSelectionExtent += result.start(1); + } else { + node.textSelectionExtent = node.value.length(); + } + } else if (!forward && node.textSelectionExtent > 0) { + // Finds last beginning of the word boundary. + Pattern pattern = Pattern.compile("(?s:.*)(\\b)\\p{L}"); + Matcher result = pattern.matcher(node.value.substring(0, node.textSelectionExtent)); + if (result.find()) { + node.textSelectionExtent = result.start(1); + } + } + break; + case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE: + if (forward && node.textSelectionExtent < node.value.length()) { + // Finds the next new line. + Pattern pattern = Pattern.compile("(?!^)(\\n)"); + Matcher result = pattern.matcher(node.value.substring(node.textSelectionExtent)); + if (result.find()) { + node.textSelectionExtent += result.start(1); + } else { + node.textSelectionExtent = node.value.length(); + } + } else if (!forward && node.textSelectionExtent > 0) { + // Finds the last new line. + Pattern pattern = Pattern.compile("(?s:.*)(\\n)"); + Matcher result = pattern.matcher(node.value.substring(0, node.textSelectionExtent)); + if (result.find()) { + node.textSelectionExtent = result.start(1); + } else { + node.textSelectionExtent = 0; + } + } + break; + case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH: + case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PAGE: + if (forward) { + node.textSelectionExtent = node.value.length(); + } else { + node.textSelectionExtent = 0; + } + break; + } + if (!extendSelection) { + node.textSelectionBase = node.textSelectionExtent; + } + } + /** * Handles the responsibilities of {@link #performAction(int, int, Bundle)} for the specific * scenario of cursor movement. */ @TargetApi(21) @RequiresApi(21) - private boolean performSetText(int virtualViewId, @NonNull Bundle arguments) { + private boolean performSetText(SemanticsNode node, int virtualViewId, @NonNull Bundle arguments) { String newText = ""; if (arguments != null && arguments.containsKey(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE)) { newText = arguments.getString(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE); } accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.SET_TEXT, newText); + // 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. + node.value = newText; return true; } diff --git a/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java b/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java index 7659f437d..ca9c01d2c 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 @@ package io.flutter.view; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; import static org.mockito.Matchers.eq; import static org.mockito.Mockito.any; import static org.mockito.Mockito.anyInt; @@ -372,6 +373,357 @@ public class AccessibilityBridgeTest { .dispatchSemanticsAction(1, AccessibilityBridge.Action.SET_TEXT, expectedText); } + @TargetApi(21) + @Test + public void itCanPredictSetText() { + 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.addFlag(AccessibilityBridge.Flag.IS_TEXT_FIELD); + root.children.add(node1); + TestSemanticsUpdate testSemanticsUpdate = root.toUpdate(); + accessibilityBridge.updateSemantics(testSemanticsUpdate.buffer, testSemanticsUpdate.strings); + Bundle bundle = new Bundle(); + String expectedText = "some string"; + bundle.putString(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, expectedText); + accessibilityBridge.performAction(1, AccessibilityNodeInfo.ACTION_SET_TEXT, bundle); + AccessibilityNodeInfo nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(1); + assertEquals(nodeInfo.getText(), expectedText); + } + + @TargetApi(21) + @Test + public void itCanCreateAccessibilityNodeInfoWithSetText() { + 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.addFlag(AccessibilityBridge.Flag.IS_TEXT_FIELD); + node1.addAction(AccessibilityBridge.Action.SET_TEXT); + root.children.add(node1); + TestSemanticsUpdate testSemanticsUpdate = root.toUpdate(); + accessibilityBridge.updateSemantics(testSemanticsUpdate.buffer, testSemanticsUpdate.strings); + AccessibilityNodeInfo nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(1); + List actions = nodeInfo.getActionList(); + assertTrue(actions.contains(AccessibilityNodeInfo.AccessibilityAction.ACTION_SET_TEXT)); + } + + @Test + public void itCanPredictSetSelection() { + 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 = -1; + node1.textSelectionExtent = -1; + node1.addFlag(AccessibilityBridge.Flag.IS_TEXT_FIELD); + root.children.add(node1); + TestSemanticsUpdate testSemanticsUpdate = root.toUpdate(); + accessibilityBridge.updateSemantics(testSemanticsUpdate.buffer, testSemanticsUpdate.strings); + Bundle bundle = new Bundle(); + int expectedStart = 1; + int expectedEnd = 3; + bundle.putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT, expectedStart); + bundle.putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT, expectedEnd); + accessibilityBridge.performAction(1, AccessibilityNodeInfo.ACTION_SET_SELECTION, bundle); + AccessibilityNodeInfo nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(1); + assertEquals(nodeInfo.getTextSelectionStart(), expectedStart); + assertEquals(nodeInfo.getTextSelectionEnd(), expectedEnd); + } + + @Test + public void itCanPredictCursorMovementsWithGranularityWord() { + 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(); + accessibilityBridge.updateSemantics(testSemanticsUpdate.buffer, testSemanticsUpdate.strings); + Bundle bundle = new Bundle(); + bundle.putInt( + AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT, + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD); + bundle.putBoolean(AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN, false); + accessibilityBridge.performAction( + 1, AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, bundle); + AccessibilityNodeInfo nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(1); + // The seletction should be at the end of 'text' + assertEquals(nodeInfo.getTextSelectionStart(), 9); + assertEquals(nodeInfo.getTextSelectionEnd(), 9); + + bundle = new Bundle(); + bundle.putInt( + AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT, + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD); + bundle.putBoolean(AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN, false); + accessibilityBridge.performAction( + 1, AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY, bundle); + nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(1); + // The seletction should be go to beginning of 'text'. + assertEquals(nodeInfo.getTextSelectionStart(), 5); + assertEquals(nodeInfo.getTextSelectionEnd(), 5); + } + + @Test + public void itCanPredictCursorMovementsWithGranularityWordUnicode() { + 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 = "你 好 嗎"; + node1.textSelectionBase = 0; + node1.textSelectionExtent = 0; + node1.addFlag(AccessibilityBridge.Flag.IS_TEXT_FIELD); + root.children.add(node1); + TestSemanticsUpdate testSemanticsUpdate = root.toUpdate(); + accessibilityBridge.updateSemantics(testSemanticsUpdate.buffer, testSemanticsUpdate.strings); + Bundle bundle = new Bundle(); + bundle.putInt( + AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT, + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD); + bundle.putBoolean(AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN, false); + accessibilityBridge.performAction( + 1, AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, bundle); + AccessibilityNodeInfo nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(1); + // The seletction should be at the end of '好' + assertEquals(nodeInfo.getTextSelectionStart(), 3); + assertEquals(nodeInfo.getTextSelectionEnd(), 3); + + bundle = new Bundle(); + bundle.putInt( + AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT, + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD); + bundle.putBoolean(AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN, false); + accessibilityBridge.performAction( + 1, AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY, bundle); + nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(1); + // The seletction should be go to beginning of '好'. + assertEquals(nodeInfo.getTextSelectionStart(), 2); + assertEquals(nodeInfo.getTextSelectionEnd(), 2); + } + + @Test + public void itCanPredictCursorMovementsWithGranularityLine() { + 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 = "How are you\nI am fine\nThank you"; + // Selection is at the second line. + node1.textSelectionBase = 14; + node1.textSelectionExtent = 14; + node1.addFlag(AccessibilityBridge.Flag.IS_TEXT_FIELD); + root.children.add(node1); + TestSemanticsUpdate testSemanticsUpdate = root.toUpdate(); + accessibilityBridge.updateSemantics(testSemanticsUpdate.buffer, testSemanticsUpdate.strings); + Bundle bundle = new Bundle(); + bundle.putInt( + AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT, + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE); + bundle.putBoolean(AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN, false); + accessibilityBridge.performAction( + 1, AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, bundle); + AccessibilityNodeInfo nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(1); + // The seletction should be at the beginning of the third line. + assertEquals(nodeInfo.getTextSelectionStart(), 21); + assertEquals(nodeInfo.getTextSelectionEnd(), 21); + + bundle = new Bundle(); + bundle.putInt( + AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT, + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE); + bundle.putBoolean(AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN, false); + accessibilityBridge.performAction( + 1, AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY, bundle); + nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(1); + // The seletction should be at the beginning of the second line. + assertEquals(nodeInfo.getTextSelectionStart(), 11); + assertEquals(nodeInfo.getTextSelectionEnd(), 11); + } + + @Test + public void itCanPredictCursorMovementsWithGranularityCharacter() { + 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(); + accessibilityBridge.updateSemantics(testSemanticsUpdate.buffer, testSemanticsUpdate.strings); + 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); + AccessibilityNodeInfo nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(1); + assertEquals(nodeInfo.getTextSelectionStart(), 1); + assertEquals(nodeInfo.getTextSelectionEnd(), 1); + + 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); + nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(1); + assertEquals(nodeInfo.getTextSelectionStart(), 0); + assertEquals(nodeInfo.getTextSelectionEnd(), 0); + } + @Test public void itAnnouncesWhiteSpaceWhenNoNamesRoute() { AccessibilityViewEmbedder mockViewEmbedder = mock(AccessibilityViewEmbedder.class); @@ -600,6 +952,10 @@ public class AccessibilityBridgeTest { flags |= flag.value; } + void addAction(AccessibilityBridge.Action action) { + actions |= action.value; + } + // These fields are declared in the order they should be // encoded. int id = 0; -- GitLab