Bug 1454081 - Fix accessible coordinates in APZ viewports. r=surkov, r=yzen, r=jchen
☠☠ backed out by 4a5a4487e760 ☠ ☠
authorEitan Isaacson <eitan@monotonous.org>
Thu, 19 Apr 2018 09:19:00 -0400
changeset 468111 99ec19154f8ae42c178030c0581551c7c46d230c
parent 468110 7c588027cbbb6274f47a2000bfe055a3163977c4
child 468112 ba3c6122001c3089c1f2429c9f31a34240ca1812
push id9165
push userasasaki@mozilla.com
push dateThu, 26 Apr 2018 21:04:54 +0000
treeherdermozilla-beta@064c3804de2e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerssurkov, yzen, jchen
bugs1454081
milestone61.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 1454081 - Fix accessible coordinates in APZ viewports. r=surkov, r=yzen, r=jchen
accessible/generic/Accessible.cpp
accessible/generic/HyperTextAccessible.cpp
accessible/jsat/AccessFu.jsm
accessible/jsat/ContentControl.jsm
accessible/jsat/Utils.jsm
accessible/jsat/content-script.js
accessible/tests/browser/bounds/browser.ini
accessible/tests/browser/bounds/browser_test_resolution.js
accessible/tests/mochitest/layout.js
mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanZoomController.java
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoView.java
mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java
--- a/accessible/generic/Accessible.cpp
+++ b/accessible/generic/Accessible.cpp
@@ -556,16 +556,21 @@ Accessible::ChildAtPoint(int32_t aX, int
     if (popupChild == popupAcc)
       startFrame = popupFrame;
   }
 
   nsPresContext* presContext = startFrame->PresContext();
   nsRect screenRect = startFrame->GetScreenRectInAppUnits();
   nsPoint offset(presContext->DevPixelsToAppUnits(aX) - screenRect.X(),
                  presContext->DevPixelsToAppUnits(aY) - screenRect.Y());
+
+  // We need to take into account a non-1 resolution set on the presshell.
+  // This happens in mobile platforms with async pinch zooming.
+  offset = offset.RemoveResolution(presContext->PresShell()->GetResolution());
+
   nsIFrame* foundFrame = nsLayoutUtils::GetFrameForPoint(startFrame, offset);
 
   nsIContent* content = nullptr;
   if (!foundFrame || !(content = foundFrame->GetContent()))
     return fallbackAnswer;
 
   // Get accessible for the node with the point or the first accessible in
   // the DOM parent chain.
@@ -674,16 +679,20 @@ Accessible::Bounds() const
 
   nsIntRect screenRect;
   nsPresContext* presContext = mDoc->PresContext();
   screenRect.SetRect(presContext->AppUnitsToDevPixels(unionRectTwips.X()),
                      presContext->AppUnitsToDevPixels(unionRectTwips.Y()),
                      presContext->AppUnitsToDevPixels(unionRectTwips.Width()),
                      presContext->AppUnitsToDevPixels(unionRectTwips.Height()));
 
+  // We need to take into account a non-1 resolution set on the presshell.
+  // This happens in mobile platforms with async pinch zooming. Here we
+  // scale the bounds before adding the screen-relative offset.
+  screenRect.ScaleRoundOut(presContext->PresShell()->GetResolution());
   // We have the union of the rectangle, now we need to put it in absolute
   // screen coords.
   nsIntRect orgRectPixels = boundingFrame->GetScreenRectInAppUnits().
     ToNearestPixels(presContext->AppUnitsPerDevPixel());
   screenRect.MoveBy(orgRectPixels.X(), orgRectPixels.Y());
 
   return screenRect;
 }
--- a/accessible/generic/HyperTextAccessible.cpp
+++ b/accessible/generic/HyperTextAccessible.cpp
@@ -1267,16 +1267,26 @@ HyperTextAccessible::TextBounds(int32_t 
 
     bounds.UnionRect(bounds, GetBoundsInFrame(frame, offset1,
                                               nextOffset - prevOffset));
 
     prevOffset = nextOffset;
     offset1 = 0;
   }
 
+  // This document may have a resolution set, we will need to multiply
+  // the document-relative coordinates by that value and re-apply the doc's
+  // screen coordinates.
+  nsPresContext* presContext = mDoc->PresContext();
+  nsIFrame* rootFrame = presContext->PresShell()->GetRootFrame();
+  nsIntRect orgRectPixels = rootFrame->GetScreenRectInAppUnits().ToNearestPixels(presContext->AppUnitsPerDevPixel());
+  bounds.MoveBy(-orgRectPixels.X(), -orgRectPixels.Y());
+  bounds.ScaleRoundOut(presContext->PresShell()->GetResolution());
+  bounds.MoveBy(orgRectPixels.X(), orgRectPixels.Y());
+
   auto boundsX = bounds.X();
   auto boundsY = bounds.Y();
   nsAccUtils::ConvertScreenCoordsTo(&boundsX, &boundsY, aCoordType, this);
   bounds.MoveTo(boundsX, boundsY);
   return bounds;
 }
 
 already_AddRefed<TextEditor>
--- a/accessible/jsat/AccessFu.jsm
+++ b/accessible/jsat/AccessFu.jsm
@@ -20,16 +20,17 @@ const GECKOVIEW_MESSAGE = {
   ACTIVATE: "GeckoView:AccessibilityActivate",
   VIEW_FOCUSED: "GeckoView:AccessibilityViewFocused",
   LONG_PRESS: "GeckoView:AccessibilityLongPress",
   BY_GRANULARITY: "GeckoView:AccessibilityByGranularity",
   NEXT: "GeckoView:AccessibilityNext",
   PREVIOUS: "GeckoView:AccessibilityPrevious",
   SCROLL_BACKWARD: "GeckoView:AccessibilityScrollBackward",
   SCROLL_FORWARD: "GeckoView:AccessibilityScrollForward",
+  EXPLORE_BY_TOUCH: "GeckoView:AccessibilityExploreByTouch"
 };
 
 var AccessFu = {
   /**
    * Initialize chrome-layer accessibility functionality.
    * If accessibility is enabled on the platform, then a special accessibility
    * mode is started.
    */
@@ -277,33 +278,36 @@ var AccessFu = {
         let method = event.replace(/GeckoView:Accessibility(\w+)/, "move$1");
         this.Input.moveCursor(method, rule, "gesture");
         break;
       }
       case GECKOVIEW_MESSAGE.ACTIVATE:
         this.Input.activateCurrent(data);
         break;
       case GECKOVIEW_MESSAGE.LONG_PRESS:
-        this.Input.sendContextMenuMessage();
+        // XXX: Advertize long press on supported objects and implement action
         break;
       case GECKOVIEW_MESSAGE.SCROLL_FORWARD:
         this.Input.androidScroll("forward");
         break;
       case GECKOVIEW_MESSAGE.SCROLL_BACKWARD:
         this.Input.androidScroll("backward");
         break;
       case GECKOVIEW_MESSAGE.VIEW_FOCUSED:
         this._focused = data.gainFocus;
         if (this._focused) {
           this.autoMove({ forcePresent: true, noOpIfOnScreen: true });
         }
         break;
       case GECKOVIEW_MESSAGE.BY_GRANULARITY:
         this.Input.moveByGranularity(data);
         break;
+      case GECKOVIEW_MESSAGE.EXPLORE_BY_TOUCH:
+        this.Input.moveToPoint("Simple", ...data.coordinates);
+        break;
     }
   },
 
   observe: function observe(aSubject, aTopic, aData) {
     switch (aTopic) {
       case "remote-browser-shown":
       case "inprocess-browser-shown":
       {
@@ -370,40 +374,29 @@ var AccessFu = {
   // Layerview is focused
   _focused: false,
 
   // Keep track of message managers tha already have a 'content-script.js'
   // injected.
   _processedMessageManagers: [],
 
   /**
-   * Adjusts the given bounds relative to the given browser.
+   * Adjusts the given bounds that are defined in device display pixels
+   * to client-relative CSS pixels of the chrome window.
    * @param {Rect} aJsonBounds the bounds to adjust
-   * @param {browser} aBrowser the browser we want the bounds relative to
-   * @param {bool} aToCSSPixels whether to convert to CSS pixels (as opposed to
-   *               device pixels)
    */
-  adjustContentBounds(aJsonBounds, aBrowser, aToCSSPixels) {
+  screenToClientBounds(aJsonBounds) {
       let bounds = new Rect(aJsonBounds.left, aJsonBounds.top,
                             aJsonBounds.right - aJsonBounds.left,
                             aJsonBounds.bottom - aJsonBounds.top);
       let win = Utils.win;
       let dpr = win.devicePixelRatio;
-      let offset = { left: -win.mozInnerScreenX, top: -win.mozInnerScreenY };
 
-      // Add the offset; the offset is in CSS pixels, so multiply the
-      // devicePixelRatio back in before adding to preserve unit consistency.
-      bounds = bounds.translate(offset.left * dpr, offset.top * dpr);
-
-      // If we want to get to CSS pixels from device pixels, this needs to be
-      // further divided by the devicePixelRatio due to widget scaling.
-      if (aToCSSPixels) {
-        bounds = bounds.scale(1 / dpr, 1 / dpr);
-      }
-
+      bounds = bounds.scale(1 / dpr, 1 / dpr);
+      bounds = bounds.translate(-win.mozInnerScreenX, -win.mozInnerScreenY);
       return bounds.expandToIntegers();
     }
 };
 
 var Output = {
   brailleState: {
     startOffset: 0,
     endOffset: 0,
@@ -512,17 +505,17 @@ var Output = {
             doc.createElementNS("http://www.w3.org/1999/xhtml", "div"));
 
           this.highlightBox = Cu.getWeakReference(highlightBox);
         } else {
           highlightBox = this.highlightBox.get();
         }
 
         let padding = aDetail.padding;
-        let r = AccessFu.adjustContentBounds(aDetail.bounds, aBrowser, true);
+        let r = AccessFu.screenToClientBounds(aDetail.bounds);
 
         // First hide it to avoid flickering when changing the style.
         highlightBox.classList.remove("show");
         highlightBox.style.top = (r.top - padding) + "px";
         highlightBox.style.left = (r.left - padding) + "px";
         highlightBox.style.width = (r.width + padding * 2) + "px";
         highlightBox.style.height = (r.height + padding * 2) + "px";
         highlightBox.classList.add("show");
@@ -541,20 +534,16 @@ var Output = {
   },
 
   Android: function Android(aDetails, aBrowser) {
     const ANDROID_VIEW_TEXT_CHANGED = 0x10;
     const ANDROID_VIEW_TEXT_SELECTION_CHANGED = 0x2000;
 
     for (let androidEvent of aDetails) {
       androidEvent.type = "GeckoView:AccessibilityEvent";
-      if (androidEvent.bounds) {
-        androidEvent.bounds = AccessFu.adjustContentBounds(
-          androidEvent.bounds, aBrowser);
-      }
 
       switch (androidEvent.eventType) {
         case ANDROID_VIEW_TEXT_CHANGED:
           androidEvent.brailleOutput = this.brailleState.adjustText(
             androidEvent.text);
           break;
         case ANDROID_VIEW_TEXT_SELECTION_CHANGED:
           androidEvent.brailleOutput = this.brailleState.adjustSelection(
@@ -832,21 +821,16 @@ var Input = {
     let mm = Utils.getMessageManager(Utils.CurrentBrowser);
     let offset = aData && typeof aData.keyIndex === "number" ?
                  aData.keyIndex - Output.brailleState.startOffset : -1;
 
     mm.sendAsyncMessage("AccessFu:Activate",
                         {offset, activateIfKey: aActivateIfKey});
   },
 
-  sendContextMenuMessage: function sendContextMenuMessage() {
-    let mm = Utils.getMessageManager(Utils.CurrentBrowser);
-    mm.sendAsyncMessage("AccessFu:ContextMenu", {});
-  },
-
   setEditState: function setEditState(aEditState) {
     Logger.debug(() => { return ["setEditState", JSON.stringify(aEditState)]; });
     this.editState = aEditState;
   },
 
   // XXX: This is here for backwards compatability with screen reader simulator
   // it should be removed when the extension is updated on amo.
   scroll: function scroll(aPage, aHorizontal) {
@@ -857,18 +841,17 @@ var Input = {
     let mm = Utils.getMessageManager(Utils.CurrentBrowser);
     mm.sendAsyncMessage("AccessFu:Scroll",
       {page: aPage, horizontal: aHorizontal, origin: "top"});
   },
 
   doScroll: function doScroll(aDetails) {
     let horizontal = aDetails.horizontal;
     let page = aDetails.page;
-    let p = AccessFu.adjustContentBounds(
-      aDetails.bounds, Utils.CurrentBrowser, true).center();
+    let p = AccessFu.screenToClientBounds(aDetails.bounds).center();
     Utils.winUtils.sendWheelEvent(p.x, p.y,
       horizontal ? page : 0, horizontal ? 0 : page, 0,
       Utils.win.WheelEvent.DOM_DELTA_PAGE, 0, 0, 0, 0);
   },
 
   get keyMap() {
     delete this.keyMap;
     this.keyMap = {
--- a/accessible/jsat/ContentControl.jsm
+++ b/accessible/jsat/ContentControl.jsm
@@ -39,25 +39,23 @@ this.ContentControl.prototype = {
                        "AccessFu:MoveByGranularity",
                        "AccessFu:AndroidScroll"],
 
   start: function cc_start() {
     let cs = this._contentScope.get();
     for (let message of this.messagesOfInterest) {
       cs.addMessageListener(message, this);
     }
-    cs.addEventListener("mousemove", this);
   },
 
   stop: function cc_stop() {
     let cs = this._contentScope.get();
     for (let message of this.messagesOfInterest) {
       cs.removeMessageListener(message, this);
     }
-    cs.removeEventListener("mousemove", this);
   },
 
   get document() {
     return this._contentScope.get().content.document;
   },
 
   get window() {
     return this._contentScope.get().content;
@@ -101,17 +99,17 @@ this.ContentControl.prototype = {
 
     // Counter-intuitive, but scrolling backward (ie. up), actually should
     // increase range values.
     if (this.adjustRange(position, aMessage.json.direction === "backward")) {
       return;
     }
 
     this._contentScope.get().sendAsyncMessage("AccessFu:DoScroll",
-      { bounds: Utils.getBounds(position, true),
+      { bounds: Utils.getBounds(position),
         page: aMessage.json.direction === "forward" ? 1 : -1,
         horizontal: false });
   },
 
   handleMoveCursor: function cc_handleMoveCursor(aMessage) {
     let origin = aMessage.json.origin;
     let action = aMessage.json.action;
     let adjustRange = aMessage.json.adjustRange;
@@ -153,34 +151,21 @@ this.ContentControl.prototype = {
       // to it.
       this.sendToParent(aMessage);
     } else {
       this._contentScope.get().sendAsyncMessage("AccessFu:Present",
         Presentation.noMove(action));
     }
   },
 
-  handleEvent: function cc_handleEvent(aEvent) {
-    if (aEvent.type === "mousemove") {
-      this.handleMoveToPoint(
-        { json: { x: aEvent.screenX, y: aEvent.screenY, rule: "Simple" } });
-    }
-    if (!Utils.getMessageManager(aEvent.target)) {
-      aEvent.preventDefault();
-    } else {
-      aEvent.target.focus();
-    }
-  },
-
   handleMoveToPoint: function cc_handleMoveToPoint(aMessage) {
     let [x, y] = [aMessage.json.x, aMessage.json.y];
     let rule = TraversalRules[aMessage.json.rule];
 
-    let dpr = this.window.devicePixelRatio;
-    this.vc.moveToPoint(rule, x * dpr, y * dpr, true);
+    this.vc.moveToPoint(rule, x, y, true);
   },
 
   handleClearCursor: function cc_handleClearCursor(aMessage) {
     let forwarded = this.sendToChild(this.vc, aMessage);
     this.vc.position = null;
     if (!forwarded) {
       this._contentScope.get().sendAsyncMessage("AccessFu:CursorCleared");
     }
--- a/accessible/jsat/Utils.jsm
+++ b/accessible/jsat/Utils.jsm
@@ -297,39 +297,31 @@ var Utils = { // jshint ignore:line
   getContentResolution: function _getContentResolution(aAccessible) {
     let res = { value: 1 };
     aAccessible.document.window.QueryInterface(
       Ci.nsIInterfaceRequestor).getInterface(
       Ci.nsIDOMWindowUtils).getResolution(res);
     return res.value;
   },
 
-  getBounds: function getBounds(aAccessible, aPreserveContentScale) {
+  getBounds: function getBounds(aAccessible) {
     let objX = {}, objY = {}, objW = {}, objH = {};
     aAccessible.getBounds(objX, objY, objW, objH);
 
-    let scale = aPreserveContentScale ? 1 :
-      this.getContentResolution(aAccessible);
-
-    return new Rect(objX.value, objY.value, objW.value, objH.value).scale(
-      scale, scale);
+    return new Rect(objX.value, objY.value, objW.value, objH.value);
   },
 
   getTextBounds: function getTextBounds(aAccessible, aStart, aEnd,
                                         aPreserveContentScale) {
     let accText = aAccessible.QueryInterface(Ci.nsIAccessibleText);
     let objX = {}, objY = {}, objW = {}, objH = {};
     accText.getRangeExtents(aStart, aEnd, objX, objY, objW, objH,
       Ci.nsIAccessibleCoordinateType.COORDTYPE_SCREEN_RELATIVE);
 
-    let scale = aPreserveContentScale ? 1 :
-      this.getContentResolution(aAccessible);
-
-    return new Rect(objX.value, objY.value, objW.value, objH.value).scale(
-      scale, scale);
+    return new Rect(objX.value, objY.value, objW.value, objH.value);
   },
 
   /**
    * Get current display DPI.
    */
   get dpi() {
     delete this.dpi;
     this.dpi = this.winUtils.displayDPI;
--- a/accessible/jsat/content-script.js
+++ b/accessible/jsat/content-script.js
@@ -57,59 +57,45 @@ function forwardToChild(aMessage, aListe
     // so we remove the real screen offset here.
     newJSON.x -= content.mozInnerScreenX;
     newJSON.y -= content.mozInnerScreenY;
   }
   mm.sendAsyncMessage(aMessage.name, newJSON);
   return true;
 }
 
-function activateContextMenu(aMessage) {
-  let position = Utils.getVirtualCursor(content.document).position;
-  if (!forwardToChild(aMessage, activateContextMenu, position)) {
-    let center = Utils.getBounds(position, true).center();
-
-    let evt = content.document.createEvent("HTMLEvents");
-    evt.initEvent("contextmenu", true, true);
-    evt.clientX = center.x;
-    evt.clientY = center.y;
-    position.DOMNode.dispatchEvent(evt);
-  }
-}
-
 function presentCaretChange(aText, aOldOffset, aNewOffset) {
   if (aOldOffset !== aNewOffset) {
     let msg = Presentation.textSelectionChanged(aText, aNewOffset, aNewOffset,
                                                 aOldOffset, aOldOffset, true);
     sendAsyncMessage("AccessFu:Present", msg);
   }
 }
 
 function scroll(aMessage) {
   let position = Utils.getVirtualCursor(content.document).position;
   if (!forwardToChild(aMessage, scroll, position)) {
     sendAsyncMessage("AccessFu:DoScroll",
-                     { bounds: Utils.getBounds(position, true),
+                     { bounds: Utils.getBounds(position),
                        page: aMessage.json.page,
                        horizontal: aMessage.json.horizontal });
   }
 }
 
 addMessageListener(
   "AccessFu:Start",
   function(m) {
     if (m.json.logLevel) {
       Logger.logLevel = Logger[m.json.logLevel];
     }
 
     Logger.debug("AccessFu:Start");
     if (m.json.buildApp)
       Utils.MozBuildApp = m.json.buildApp;
 
-    addMessageListener("AccessFu:ContextMenu", activateContextMenu);
     addMessageListener("AccessFu:Scroll", scroll);
 
     if (!contentControl) {
       contentControl = new ContentControl(this);
     }
     contentControl.start();
 
     if (!eventManager) {
@@ -134,16 +120,15 @@ addMessageListener(
     }
   });
 
 addMessageListener(
   "AccessFu:Stop",
   function(m) {
     Logger.debug("AccessFu:Stop");
 
-    removeMessageListener("AccessFu:ContextMenu", activateContextMenu);
     removeMessageListener("AccessFu:Scroll", scroll);
 
     eventManager.stop();
     contentControl.stop();
   });
 
 sendAsyncMessage("AccessFu:Ready");
--- a/accessible/tests/browser/bounds/browser.ini
+++ b/accessible/tests/browser/bounds/browser.ini
@@ -1,11 +1,12 @@
 [DEFAULT]
 support-files =
   head.js
   !/accessible/tests/browser/events.js
   !/accessible/tests/browser/shared-head.js
   !/accessible/tests/mochitest/*.js
   !/accessible/tests/mochitest/letters.gif
 
+[browser_test_resolution.js]
 [browser_test_zoom.js]
 [browser_test_zoom_text.js]
 skip-if = e10s && os == 'win' # bug 1372296
new file mode 100644
--- /dev/null
+++ b/accessible/tests/browser/bounds/browser_test_resolution.js
@@ -0,0 +1,57 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* import-globals-from ../../mochitest/layout.js */
+
+async function testScaledBounds(browser, accDoc, scale, id, type = "object") {
+  let acc = findAccessibleChildByID(accDoc, id);
+
+  // Get document offset
+  let [docX, docY] = getBounds(accDoc);
+
+  // Get the unscaled bounds of the accessible
+  let [x, y, width, height] = type == "text" ?
+    getRangeExtents(acc, 0, -1, COORDTYPE_SCREEN_RELATIVE) : getBounds(acc);
+
+  await ContentTask.spawn(browser, scale, _scale => {
+    setResolution(document, _scale);
+  });
+
+  let [scaledX, scaledY, scaledWidth, scaledHeight] = type == "text" ?
+    getRangeExtents(acc, 0, -1, COORDTYPE_SCREEN_RELATIVE) : getBounds(acc);
+
+  let name = prettyName(acc);
+  isWithin(scaledWidth, width * scale, 2, "Wrong scaled width of " + name);
+  isWithin(scaledHeight, height * scale, 2, "Wrong scaled height of " + name);
+  isWithin(scaledX - docX, (x - docX) * scale, 2, "Wrong scaled x of " + name);
+  isWithin(scaledY - docY, (y - docY) * scale, 2, "Wrong scaled y of " + name);
+
+  await ContentTask.spawn(browser, {}, () => {
+    setResolution(document, 1.0);
+  });
+}
+
+async function runTests(browser, accDoc) {
+  loadFrameScripts(browser, { name: "layout.js", dir: MOCHITESTS_DIR });
+
+  await testScaledBounds(browser, accDoc, 2.0, "p1");
+  await testScaledBounds(browser, accDoc, 0.5, "p2");
+  await testScaledBounds(browser, accDoc, 3.5, "b1");
+
+  await testScaledBounds(browser, accDoc, 2.0, "p1", "text");
+  await testScaledBounds(browser, accDoc, 0.75, "p2", "text");
+}
+
+/**
+ * Test accessible boundaries when page is zoomed
+ */
+addAccessibleTask(`
+<p id='p1' style='font-family: monospace;'>Tilimilitryamdiya</p>
+<p id="p2">para 2</p>
+<button id="b1">Hello</button>
+`,
+  runTests
+);
--- a/accessible/tests/mochitest/layout.js
+++ b/accessible/tests/mochitest/layout.js
@@ -64,16 +64,28 @@ function zoomDocument(aDocument, aZoom) 
     getInterface(Ci.nsIWebNavigation).
     QueryInterface(Ci.nsIDocShell);
   var docViewer = docShell.contentViewer;
 
   docViewer.fullZoom = aZoom;
 }
 
 /**
+ * Set the relative resolution of this document. This is what apz does.
+ * On non-mobile platforms you won't see a visible change.
+ */
+function setResolution(aDocument, aZoom) {
+  var windowUtils = aDocument.defaultView.
+    QueryInterface(Ci.nsIInterfaceRequestor).
+    getInterface(Ci.nsIDOMWindowUtils);
+
+  windowUtils.setResolutionAndScaleTo(aZoom);
+}
+
+/**
  * Return child accessible at the given point.
  *
  * @param aIdentifier        [in] accessible identifier
  * @param aX                 [in] x coordinate of the point relative accessible
  * @param aY                 [in] y coordinate of the point relative accessible
  * @param aFindDeepestChild  [in] points whether deepest or nearest child should
  *                           be returned
  * @return                   the child accessible at the given point
@@ -191,16 +203,24 @@ function getPos(aID) {
  */
 function getBounds(aID) {
   var accessible = getAccessible(aID);
   var x = {}, y = {}, width = {}, height = {};
   accessible.getBounds(x, y, width, height);
   return [x.value, y.value, width.value, height.value];
 }
 
+function getRangeExtents(aID, aStartOffset, aEndOffset, aCoordOrigin) {
+  var hyperText = getAccessible(aID, [nsIAccessibleText]);
+  var x = {}, y = {}, width = {}, height = {};
+  hyperText.getRangeExtents(aStartOffset, aEndOffset,
+                            x, y, width, height, aCoordOrigin);
+  return [x.value, y.value, width.value, height.value];
+}
+
 /**
  * Return DOM node coordinates relative the screen and its size in device
  * pixels.
  */
 function getBoundsForDOMElm(aID) {
   var x = 0, y = 0, width = 0, height = 0;
 
   var elm = getNode(aID);
--- a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanZoomController.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanZoomController.java
@@ -223,16 +223,21 @@ public final class PanZoomController ext
             } else if ((InputDevice.getDevice(event.getDeviceId()).getSources() &
                         InputDevice.SOURCE_TOUCHPAD) == InputDevice.SOURCE_TOUCHPAD) {
                 return false;
             }
             return handleScrollEvent(event);
         } else if ((action == MotionEvent.ACTION_HOVER_MOVE) ||
                    (action == MotionEvent.ACTION_HOVER_ENTER) ||
                    (action == MotionEvent.ACTION_HOVER_EXIT)) {
+            if (event.getSource() == InputDevice.SOURCE_TOUCHSCREEN) {
+                // A hover is not possible on a touchscreen unless via accessibility
+                // and we handle that elsewhere.
+                return false;
+            }
             return handleMouseEvent(event);
         } else {
             return false;
         }
     }
 
     private void enableEventQueue() {
         if (mQueuedEvents != null) {
--- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoView.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoView.java
@@ -511,25 +511,25 @@ public class GeckoView extends FrameLayo
         // NOTE: Treat mouse events as "touch" rather than as "mouse", so mouse can be
         // used to pan/zoom. Call onMouseEvent() instead for behavior similar to desktop.
         return mSession != null &&
                mSession.getPanZoomController().onTouchEvent(event);
     }
 
     @Override
     public boolean onHoverEvent(final MotionEvent event) {
-        // If we get a touchscreen hover event, and accessibility is not enabled, don't
-        // send it to Gecko.
-        if (event.getSource() == InputDevice.SOURCE_TOUCHSCREEN &&
-            !SessionAccessibility.Settings.isEnabled()) {
-            return false;
+        // A touchscreen hover event is a screen reader doing explore-by-touch
+        if (SessionAccessibility.Settings.isEnabled() &&
+            event.getSource() == InputDevice.SOURCE_TOUCHSCREEN &&
+            mSession != null) {
+            mSession.getAccessibility().onExploreByTouch(event);
+            return true;
         }
 
-        return mSession != null &&
-               mSession.getPanZoomController().onMotionEvent(event);
+        return false;
     }
 
     @Override
     public boolean onGenericMotionEvent(final MotionEvent event) {
         if (AndroidGamepadManager.handleMotionEvent(event)) {
             return true;
         }
 
--- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java
@@ -14,16 +14,17 @@ import org.mozilla.gecko.util.ThreadUtil
 
 import android.annotation.TargetApi;
 import android.content.Context;
 import android.graphics.Matrix;
 import android.graphics.Rect;
 import android.os.Build;
 import android.os.Bundle;
 import android.util.Log;
+import android.view.MotionEvent;
 import android.view.View;
 import android.view.ViewParent;
 import android.view.accessibility.AccessibilityEvent;
 import android.view.accessibility.AccessibilityManager;
 import android.view.accessibility.AccessibilityNodeInfo;
 import android.view.accessibility.AccessibilityNodeProvider;
 
 public class SessionAccessibility {
@@ -361,27 +362,27 @@ public class SessionAccessibility {
                 sb.append(' ').append(textArray[i] != null ? textArray[i] : "");
             }
             node.setText(sb.toString());
         }
         node.setContentDescription(message.getString("description", ""));
 
         final GeckoBundle bounds = message.getBundle("bounds");
         if (bounds != null) {
-            Rect relativeBounds = new Rect(bounds.getInt("left"), bounds.getInt("top"),
-                                           bounds.getInt("right"), bounds.getInt("bottom"));
-            node.setBoundsInParent(relativeBounds);
+            Rect screenBounds = new Rect(bounds.getInt("left"), bounds.getInt("top"),
+                                         bounds.getInt("right"), bounds.getInt("bottom"));
+            node.setBoundsInScreen(screenBounds);
 
             final Matrix matrix = new Matrix();
             final float[] origin = new float[2];
             mSession.getClientToScreenMatrix(matrix);
             matrix.mapPoints(origin);
 
-            relativeBounds.offset((int) origin[0], (int) origin[1]);
-            node.setBoundsInScreen(relativeBounds);
+            screenBounds.offset((int) -origin[0], (int) -origin[1]);
+            node.setBoundsInParent(screenBounds);
         }
 
     }
 
     private void sendAccessibilityEvent(final GeckoBundle message) {
         if (mView == null || !Settings.isEnabled())
             return;
 
@@ -414,9 +415,15 @@ public class SessionAccessibility {
             }
             populateNodeInfoFromJSON(mVirtualContentNode, message);
         }
 
         final AccessibilityEvent accessibilityEvent = obtainEvent(eventType, eventSource);
         populateEventFromJSON(accessibilityEvent, message);
         ((ViewParent) mView).requestSendAccessibilityEvent(mView, accessibilityEvent);
     }
+
+    public void onExploreByTouch(final MotionEvent event) {
+      final GeckoBundle data = new GeckoBundle(2);
+      data.putDoubleArray("coordinates", new double[] {event.getRawX(), event.getRawY()});
+      mSession.getEventDispatcher().dispatch("GeckoView:AccessibilityExploreByTouch", data);
+    }
 }