Bug 1564549 - Implement text navigation natively. r=geckoview-reviewers,Jamie,snorp
authorEitan Isaacson <eitan@monotonous.org>
Mon, 23 Sep 2019 23:42:47 +0000
changeset 494800 9bbf2b19dcfdae7c9126d7f4a2b8e90efa67e8b5
parent 494799 414ba8c039e6bad3f21ef3da8fa16a5aab684d99
child 494801 95544c7598d53d71f5223240488656717b83e0d7
push id114131
push userdluca@mozilla.com
push dateThu, 26 Sep 2019 09:47:34 +0000
treeherdermozilla-inbound@1dc1a755079a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersgeckoview-reviewers, Jamie, snorp
bugs1564549
milestone71.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1564549 - Implement text navigation natively. r=geckoview-reviewers,Jamie,snorp Differential Revision: https://phabricator.services.mozilla.com/D45600
accessible/android/AccessibleWrap.cpp
accessible/android/AccessibleWrap.h
accessible/android/ProxyAccessibleWrap.cpp
accessible/android/ProxyAccessibleWrap.h
accessible/android/SessionAccessibility.cpp
accessible/android/SessionAccessibility.h
accessible/ipc/extension/android/DocAccessiblePlatformExtChild.cpp
accessible/ipc/extension/android/DocAccessiblePlatformExtChild.h
accessible/ipc/extension/android/PDocAccessiblePlatformExt.ipdl
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AccessibilityTest.kt
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java
--- a/accessible/android/AccessibleWrap.cpp
+++ b/accessible/android/AccessibleWrap.cpp
@@ -296,16 +296,88 @@ void AccessibleWrap::ExploreByTouch(floa
     RefPtr<AccEvent> event =
         new AccVCChangeEvent(result->Document(), this, -1, -1, result, -1, -1,
                              nsIAccessiblePivot::REASON_POINT,
                              nsIAccessiblePivot::NO_BOUNDARY, eFromUserInput);
     nsEventShell::FireEvent(event);
   }
 }
 
+void AccessibleWrap::NavigateText(int32_t aGranularity, int32_t aStartOffset,
+                                  int32_t aEndOffset, bool aForward,
+                                  bool aSelect) {
+  a11y::Pivot pivot(RootAccessible());
+
+  HyperTextAccessible* editable =
+      (State() & states::EDITABLE) != 0 ? AsHyperText() : nullptr;
+
+  int32_t start = aStartOffset, end = aEndOffset;
+  // If the accessible is an editable, set the virtual cursor position
+  // to its caret offset. Otherwise use the document's virtual cursor
+  // position as a starting offset.
+  if (editable) {
+    start = end = editable->CaretOffset();
+  }
+
+  uint16_t pivotGranularity = nsIAccessiblePivot::LINE_BOUNDARY;
+  switch (aGranularity) {
+    case 1:  // MOVEMENT_GRANULARITY_CHARACTER
+      pivotGranularity = nsIAccessiblePivot::CHAR_BOUNDARY;
+      break;
+    case 2:  // MOVEMENT_GRANULARITY_WORD
+      pivotGranularity = nsIAccessiblePivot::WORD_BOUNDARY;
+      break;
+    default:
+      break;
+  }
+
+  int32_t newOffset;
+  Accessible* newAnchor = nullptr;
+  if (aForward) {
+    newAnchor = pivot.NextText(this, &start, &end, pivotGranularity);
+    newOffset = end;
+  } else {
+    newAnchor = pivot.PrevText(this, &start, &end, pivotGranularity);
+    newOffset = start;
+  }
+
+  if (newAnchor && (start != aStartOffset || end != aEndOffset)) {
+    RefPtr<AccEvent> event = new AccVCChangeEvent(
+        newAnchor->Document(), this, aStartOffset, aEndOffset, newAnchor, start,
+        end, nsIAccessiblePivot::REASON_NONE, pivotGranularity, eFromUserInput);
+    nsEventShell::FireEvent(event);
+  }
+
+  // If we are in an editable, move the caret to the new virtual cursor
+  // offset.
+  if (editable) {
+    if (aSelect) {
+      int32_t anchor = editable->CaretOffset();
+      if (editable->SelectionCount()) {
+        int32_t startSel, endSel;
+        GetSelectionOrCaret(&startSel, &endSel);
+        anchor = startSel == anchor ? endSel : startSel;
+      }
+      editable->SetSelectionBoundsAt(0, anchor, newOffset);
+    } else {
+      editable->SetCaretOffset(newOffset);
+    }
+  }
+}
+
+void AccessibleWrap::GetSelectionOrCaret(int32_t* aStartOffset,
+                                         int32_t* aEndOffset) {
+  *aStartOffset = *aEndOffset = -1;
+  if (HyperTextAccessible* textAcc = AsHyperText()) {
+    if (!textAcc->SelectionBoundsAt(0, aStartOffset, aEndOffset)) {
+      *aStartOffset = *aEndOffset = textAcc->CaretOffset();
+    }
+  }
+}
+
 uint32_t AccessibleWrap::GetFlags(role aRole, uint64_t aState,
                                   uint8_t aActionCount) {
   uint32_t flags = 0;
   if (aState & states::CHECKABLE) {
     flags |= java::SessionAccessibility::FLAG_CHECKABLE;
   }
 
   if (aState & states::CHECKED) {
--- a/accessible/android/AccessibleWrap.h
+++ b/accessible/android/AccessibleWrap.h
@@ -34,16 +34,19 @@ class AccessibleWrap : public Accessible
   virtual void GetTextContents(nsAString& aText);
 
   virtual bool GetSelectionBounds(int32_t* aStartOffset, int32_t* aEndOffset);
 
   virtual void Pivot(int32_t aGranularity, bool aForward, bool aInclusive);
 
   virtual void ExploreByTouch(float aX, float aY);
 
+  virtual void NavigateText(int32_t aGranularity, int32_t aStartOffset,
+                            int32_t aEndOffset, bool aForward, bool aSelect);
+
   mozilla::java::GeckoBundle::LocalRef ToBundle(bool aSmall = false);
 
   mozilla::java::GeckoBundle::LocalRef ToBundle(
       const uint64_t aState, const nsIntRect& aBounds,
       const uint8_t aActionCount, const nsString& aName,
       const nsString& aTextValue, const nsString& aDOMNodeID,
       const nsString& aDescription,
       const double& aCurVal = UnspecifiedNaN<double>(),
@@ -84,16 +87,18 @@ class AccessibleWrap : public Accessible
                                 double* aMaxVal, double* aStep);
 
   virtual role WrapperRole() { return Role(); }
 
   void GetTextEquiv(nsString& aText);
 
   bool HandleLiveRegionEvent(AccEvent* aEvent);
 
+  void GetSelectionOrCaret(int32_t* aStartOffset, int32_t* aEndOffset);
+
   static void GetRoleDescription(role aRole,
                                  nsIPersistentProperties* aAttributes,
                                  nsAString& aGeckoRole,
                                  nsAString& aRoleDescription);
 
   static uint32_t GetFlags(role aRole, uint64_t aState, uint8_t aActionCount);
 };
 
--- a/accessible/android/ProxyAccessibleWrap.cpp
+++ b/accessible/android/ProxyAccessibleWrap.cpp
@@ -115,16 +115,23 @@ void ProxyAccessibleWrap::Pivot(int32_t 
       Proxy()->ID(), aGranularity, aForward, aInclusive);
 }
 
 void ProxyAccessibleWrap::ExploreByTouch(float aX, float aY) {
   Unused << Proxy()->Document()->GetPlatformExtension()->SendExploreByTouch(
       Proxy()->ID(), aX, aY);
 }
 
+void ProxyAccessibleWrap::NavigateText(int32_t aGranularity,
+                                       int32_t aStartOffset, int32_t aEndOffset,
+                                       bool aForward, bool aSelect) {
+  Unused << Proxy()->Document()->GetPlatformExtension()->SendNavigateText(
+      Proxy()->ID(), aGranularity, aStartOffset, aEndOffset, aForward, aSelect);
+}
+
 role ProxyAccessibleWrap::WrapperRole() { return Proxy()->Role(); }
 
 AccessibleWrap* ProxyAccessibleWrap::WrapperParent() {
   return Proxy()->Parent() ? WrapperFor(Proxy()->Parent()) : nullptr;
 }
 
 bool ProxyAccessibleWrap::WrapperRangeInfo(double* aCurVal, double* aMinVal,
                                            double* aMaxVal, double* aStep) {
--- a/accessible/android/ProxyAccessibleWrap.h
+++ b/accessible/android/ProxyAccessibleWrap.h
@@ -57,16 +57,20 @@ class ProxyAccessibleWrap : public Acces
   virtual void GetTextContents(nsAString& aText) override;
 
   virtual bool GetSelectionBounds(int32_t* aStartOffset,
                                   int32_t* aEndOffset) override;
 
   virtual void Pivot(int32_t aGranularity, bool aForward,
                      bool aInclusive) override;
 
+  virtual void NavigateText(int32_t aGranularity, int32_t aStartOffset,
+                            int32_t aEndOffset, bool aForward,
+                            bool aSelect) override;
+
   virtual void ExploreByTouch(float aX, float aY) override;
 
   virtual void WrapperDOMNodeID(nsString& aDOMNodeID) override;
 
  private:
   virtual role WrapperRole() override;
 
   virtual AccessibleWrap* WrapperParent() override;
--- a/accessible/android/SessionAccessibility.cpp
+++ b/accessible/android/SessionAccessibility.cpp
@@ -119,16 +119,24 @@ void SessionAccessibility::Pivot(int32_t
                                  bool aForward, bool aInclusive) {
   FORWARD_ACTION_TO_ACCESSIBLE(Pivot, aGranularity, aForward, aInclusive);
 }
 
 void SessionAccessibility::ExploreByTouch(int32_t aID, float aX, float aY) {
   FORWARD_ACTION_TO_ACCESSIBLE(ExploreByTouch, aX, aY);
 }
 
+void SessionAccessibility::NavigateText(int32_t aID, int32_t aGranularity,
+                                        int32_t aStartOffset,
+                                        int32_t aEndOffset, bool aForward,
+                                        bool aSelect) {
+  FORWARD_ACTION_TO_ACCESSIBLE(NavigateText, aGranularity, aStartOffset,
+                               aEndOffset, aForward, aSelect);
+}
+
 SessionAccessibility* SessionAccessibility::GetInstanceFor(
     ProxyAccessible* aAccessible) {
   auto tab =
       static_cast<dom::BrowserParent*>(aAccessible->Document()->Manager());
   dom::Element* frame = tab->GetOwnerElement();
   MOZ_ASSERT(frame);
   if (!frame) {
     return nullptr;
--- a/accessible/android/SessionAccessibility.h
+++ b/accessible/android/SessionAccessibility.h
@@ -51,16 +51,18 @@ class SessionAccessibility final
   // Native implementations
   using Base::AttachNative;
   using Base::DisposeNative;
   jni::Object::LocalRef GetNodeInfo(int32_t aID);
   void SetText(int32_t aID, jni::String::Param aText);
   void Click(int32_t aID);
   void Pivot(int32_t aID, int32_t aGranularity, bool aForward, bool aInclusive);
   void ExploreByTouch(int32_t aID, float aX, float aY);
+  void NavigateText(int32_t aID, int32_t aGranularity, int32_t aStartOffset,
+                    int32_t aEndOffset, bool aForward, bool aSelect);
   void StartNativeAccessibility();
 
   // Event methods
   void SendFocusEvent(AccessibleWrap* aAccessible);
   void SendScrollingEvent(AccessibleWrap* aAccessible, int32_t aScrollX,
                           int32_t aScrollY, int32_t aMaxScrollX,
                           int32_t aMaxScrollY);
   MOZ_CAN_RUN_SCRIPT
--- a/accessible/ipc/extension/android/DocAccessiblePlatformExtChild.cpp
+++ b/accessible/ipc/extension/android/DocAccessiblePlatformExtChild.cpp
@@ -17,20 +17,21 @@ mozilla::ipc::IPCResult DocAccessiblePla
   if (auto acc = IdToAccessibleWrap(aID)) {
     acc->Pivot(aGranularity, aForward, aInclusive);
   }
 
   return IPC_OK();
 }
 
 mozilla::ipc::IPCResult DocAccessiblePlatformExtChild::RecvNavigateText(
-    int32_t aID, int32_t aGranularity, int32_t aStartOffset, int32_t aEndOffset,
+    uint64_t aID, int32_t aGranularity, int32_t aStartOffset, int32_t aEndOffset,
     bool aForward, bool aSelect) {
   if (auto acc = IdToAccessibleWrap(aID)) {
-    // XXX: Forward to appropriate wrapper method.
+    acc->NavigateText(aGranularity, aStartOffset, aEndOffset, aForward,
+                      aSelect);
   }
 
   return IPC_OK();
 }
 
 mozilla::ipc::IPCResult DocAccessiblePlatformExtChild::RecvSetSelection(
     uint64_t aID, int32_t aStart, int32_t aEnd) {
   if (auto acc = IdToAccessibleWrap(aID)) {
--- a/accessible/ipc/extension/android/DocAccessiblePlatformExtChild.h
+++ b/accessible/ipc/extension/android/DocAccessiblePlatformExtChild.h
@@ -14,17 +14,17 @@ namespace a11y {
 class AccessibleWrap;
 class DocAccessibleChild;
 
 class DocAccessiblePlatformExtChild : public PDocAccessiblePlatformExtChild {
  public:
   mozilla::ipc::IPCResult RecvPivot(uint64_t aID, int32_t aGranularity,
                                     bool aForward, bool aInclusive);
 
-  mozilla::ipc::IPCResult RecvNavigateText(int32_t aID, int32_t aGranularity,
+  mozilla::ipc::IPCResult RecvNavigateText(uint64_t aID, int32_t aGranularity,
                                            int32_t aStartOffset,
                                            int32_t aEndOffset, bool aForward,
                                            bool aSelect);
 
   mozilla::ipc::IPCResult RecvSetSelection(uint64_t aID, int32_t aStart,
                                            int32_t aEnd);
 
   mozilla::ipc::IPCResult RecvCut(uint64_t aID);
--- a/accessible/ipc/extension/android/PDocAccessiblePlatformExt.ipdl
+++ b/accessible/ipc/extension/android/PDocAccessiblePlatformExt.ipdl
@@ -12,17 +12,17 @@ namespace a11y {
 protocol PDocAccessiblePlatformExt {
   manager PDocAccessible;
 
 child:
   async __delete__();
 
   async Pivot(uint64_t aID, int32_t aGranularity, bool aForward, bool aInclusive);
 
-  async NavigateText(int32_t aID, int32_t aGranularity, int32_t aStartOffset, int32_t aEndOffset, bool aForward, bool aSelect);
+  async NavigateText(uint64_t aID, int32_t aGranularity, int32_t aStartOffset, int32_t aEndOffset, bool aForward, bool aSelect);
 
   async SetSelection(int32_t aID, int32_t aStart, int32_t aEnd);
 
   async Cut(int32_t aID);
 
   async Copy(int32_t aID);
 
   async Paste(int32_t aID);
--- a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AccessibilityTest.kt
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AccessibilityTest.kt
@@ -313,24 +313,27 @@ class ZZAccessibilityTest : BaseSessionT
                 override fun onTextSelectionChanged(event: AccessibilityEvent) {
                     eventFromIndex = event.fromIndex;
                     eventToIndex = event.toIndex;
                 }
             })
         } while (fromIndex != eventFromIndex || toIndex != eventToIndex)
     }
 
-    private fun waitUntilTextTraversed(fromIndex: Int, toIndex: Int) {
+    private fun waitUntilTextTraversed(fromIndex: Int, toIndex: Int): Int {
+        var nodeId: Int = AccessibilityNodeProvider.HOST_VIEW_ID
         sessionRule.waitUntilCalled(object : EventDelegate {
             @AssertCalled(count = 1)
             override fun onTextTraversal(event: AccessibilityEvent) {
+              nodeId = getSourceId(event)
               assertThat("fromIndex matches", event.fromIndex, equalTo(fromIndex))
               assertThat("toIndex matches", event.toIndex, equalTo(toIndex))
             }
         })
+        return nodeId
     }
 
     private fun waitUntilClick(checked: Boolean) {
         sessionRule.waitUntilCalled(object : EventDelegate {
             @AssertCalled(count = 1)
             override fun onClicked(event: AccessibilityEvent) {
                 var nodeId = getSourceId(event)
                 var node = createNodeInfo(nodeId)
@@ -409,16 +412,32 @@ class ZZAccessibilityTest : BaseSessionT
 
         provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_PASTE, null)
         sessionRule.waitUntilCalled(object : EventDelegate {
             @AssertCalled
             override fun onTextChanged(event: AccessibilityEvent) {
                 assertThat("text should be pasted", event.text[0].toString(), equalTo("hello cruel cruel cruel"))
             }
         })
+
+        provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SET_SELECTION, setSelectionArguments(0, 0))
+        waitUntilTextSelectionChanged(0, 0)
+
+        provider.performAction(nodeId,
+                AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
+                moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD, true))
+        waitUntilTextSelectionChanged(0, 5)
+
+        provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_CUT, null)
+        sessionRule.waitUntilCalled(object : EventDelegate {
+            @AssertCalled
+            override fun onTextChanged(event: AccessibilityEvent) {
+                assertThat("text should be cut", event.text[0].toString(), equalTo(" cruel cruel cruel"))
+            }
+        })
     }
 
     @Test fun testMoveByCharacter() {
         var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID
         sessionRule.session.loadTestPath(LOREM_IPSUM_HTML_PATH)
         waitForInitialFocus(true)
 
         sessionRule.waitUntilCalled(object : EventDelegate {
@@ -428,27 +447,27 @@ class ZZAccessibilityTest : BaseSessionT
                 val node = createNodeInfo(nodeId)
                 assertThat("Accessibility focus on first paragraph", node.text as String, startsWith("Lorem ipsum"))
             }
         })
 
         provider.performAction(nodeId,
                 AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
                 moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER))
-        waitUntilTextTraversed(0, 1) // "L"
+        nodeId = waitUntilTextTraversed(0, 1) // "L"
 
         provider.performAction(nodeId,
                 AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
                 moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER))
-        waitUntilTextTraversed(1, 2) // "o"
+        nodeId = waitUntilTextTraversed(1, 2) // "o"
 
         provider.performAction(nodeId,
                 AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY,
                 moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER))
-        waitUntilTextTraversed(0, 1) // "L"
+        nodeId = waitUntilTextTraversed(0, 1) // "L"
     }
 
     @Test fun testMoveByWord() {
         var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID
         sessionRule.session.loadTestPath(LOREM_IPSUM_HTML_PATH)
         waitForInitialFocus(true)
 
         sessionRule.waitUntilCalled(object : EventDelegate {
@@ -458,22 +477,22 @@ class ZZAccessibilityTest : BaseSessionT
                 val node = createNodeInfo(nodeId)
                 assertThat("Accessibility focus on first paragraph", node.text as String, startsWith("Lorem ipsum"))
             }
         })
 
         provider.performAction(nodeId,
                 AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
                 moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD))
-        waitUntilTextTraversed(0, 5) // "Lorem"
+        nodeId = waitUntilTextTraversed(0, 5) // "Lorem"
 
         provider.performAction(nodeId,
                 AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
                 moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD))
-        waitUntilTextTraversed(6, 11) // "ipsum"
+        nodeId = waitUntilTextTraversed(6, 11) // "ipsum"
 
         provider.performAction(nodeId,
                 AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY,
                 moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD))
         waitUntilTextTraversed(0, 5) // "Lorem"
     }
 
     @Test fun testMoveByLine() {
@@ -491,27 +510,27 @@ class ZZAccessibilityTest : BaseSessionT
                 val node = createNodeInfo(nodeId)
                 assertThat("Accessibility focus on first paragraph", node.text as String, startsWith("Lorem ipsum"))
             }
         })
 
         provider.performAction(nodeId,
                 AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
                 moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE))
-        waitUntilTextTraversed(0, 18) // "Lorem ipsum dolor "
+        nodeId = waitUntilTextTraversed(0, 18) // "Lorem ipsum dolor "
 
         provider.performAction(nodeId,
                 AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
                 moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE))
-        waitUntilTextTraversed(18, 28) // "sit amet, "
+        nodeId = waitUntilTextTraversed(18, 28) // "sit amet, "
 
         provider.performAction(nodeId,
                 AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY,
                 moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE))
-        waitUntilTextTraversed(0, 18) // "Lorem ipsum dolor "
+        nodeId = waitUntilTextTraversed(0, 18) // "Lorem ipsum dolor "
     }
 
     @Test fun testHeadings() {
         var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID;
         loadTestPage("test-headings")
         waitForInitialFocus()
 
         val bundle = Bundle()
--- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java
@@ -266,21 +266,17 @@ public class SessionAccessibility {
                     int granularity = arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT);
                     if (granularity <= BRAILLE_CLICK_BASE_INDEX) {
                         int keyIndex = BRAILLE_CLICK_BASE_INDEX - granularity;
                         data = new GeckoBundle(1);
                         data.putInt("keyIndex", keyIndex);
                         mSession.getEventDispatcher().dispatch("GeckoView:AccessibilityActivate", data);
                     } else if (granularity > 0) {
                         boolean extendSelection = arguments.getBoolean(AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN);
-                        data = new GeckoBundle(3);
-                        data.putString("direction", action == AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY ? "Next" : "Previous");
-                        data.putInt("granularity", granularity);
-                        data.putBoolean("select", extendSelection);
-                        mSession.getEventDispatcher().dispatch("GeckoView:AccessibilityByGranularity", data);
+                        nativeProvider.navigateText(virtualViewId, granularity, mStartOffset, mEndOffset, action == AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, extendSelection);
                     }
                     return true;
                 case AccessibilityNodeInfo.ACTION_SET_SELECTION:
                     if (arguments == null) {
                         return false;
                     }
                     int selectionStart = arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT);
                     int selectionEnd = arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT);
@@ -543,16 +539,18 @@ public class SessionAccessibility {
     private View mView;
     // The native portion of the node provider.
     /* package */ final NativeProvider nativeProvider = new NativeProvider();
     private boolean mAttached = false;
     // The current node with accessibility focus
     private int mAccessibilityFocusedNode = 0;
     // The current node with focus
     private int mFocusedNode = 0;
+    private int mStartOffset = -1;
+    private int mEndOffset = -1;
     // Viewport cache
     final SparseArray<GeckoBundle> mViewportCache = new SparseArray<>();
     // Focus cache
     final SparseArray<GeckoBundle> mFocusPathCache = new SparseArray<>();
     // List of caches in descending order from last updated.
     LinkedList<SparseArray<GeckoBundle>> mCaches = new LinkedList<>();
     private boolean mViewFocusRequested = false;
 
@@ -802,25 +800,31 @@ public class SessionAccessibility {
                 }
                 break;
             case AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED:
                 if (mAccessibilityFocusedNode == sourceId) {
                     mAccessibilityFocusedNode = 0;
                 }
                 break;
             case AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED:
+                mStartOffset = -1;
+                mEndOffset = -1;
                 mAccessibilityFocusedNode = sourceId;
                 break;
             case AccessibilityEvent.TYPE_VIEW_FOCUSED:
                 mFocusedNode = sourceId;
                 if (!mView.isFocused() && !isInTest()) {
                     // Don't dispatch a focus event if the parent view is not focused
                     return;
                 }
                 break;
+            case AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY:
+                mStartOffset = event.getFromIndex();
+                mEndOffset = event.getToIndex();
+                break;
         }
 
         ((ViewParent) mView).requestSendAccessibilityEvent(mView, event);
     }
 
     private synchronized GeckoBundle getMostRecentBundle(final int virtualViewId) {
         Iterator<SparseArray<GeckoBundle>> iter = mCaches.descendingIterator();
         while (iter.hasNext()) {
@@ -859,16 +863,19 @@ public class SessionAccessibility {
         }
 
         @WrapForJNI(dispatchTo = "gecko", stubName = "Pivot")
         public native void pivotNative(int id, int granularity, boolean forward, boolean inclusive);
 
         @WrapForJNI(dispatchTo = "gecko")
         public native void exploreByTouch(int id, float x, float y);
 
+        @WrapForJNI(dispatchTo = "gecko")
+        public native void navigateText(int id, int granularity, int startOffset, int endOffset, boolean forward, boolean select);
+
         @WrapForJNI(calledFrom = "gecko", stubName = "SendEvent")
         private void sendEventNative(final int eventType, final int sourceId, final int className, final GeckoBundle eventData) {
             ThreadUtils.postToUiThread(new Runnable() {
                 @Override
                 public void run() {
                     sendEvent(eventType, sourceId, className, eventData);
                 }
             });