/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
 * 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/. */

package org.mozilla.geckoview;

import android.content.Context;
import android.graphics.Matrix;
import android.graphics.Rect;
import android.os.Build;
import android.os.Bundle;
import android.text.InputType;
import android.text.TextUtils;
import android.util.Log;
import android.view.InputDevice;
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.AccessibilityNodeInfo.CollectionInfo;
import android.view.accessibility.AccessibilityNodeInfo.CollectionItemInfo;
import android.view.accessibility.AccessibilityNodeInfo.RangeInfo;
import android.view.accessibility.AccessibilityNodeProvider;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import org.mozilla.gecko.GeckoAppShell;
import org.mozilla.gecko.GeckoThread;
import org.mozilla.gecko.annotation.WrapForJNI;
import org.mozilla.gecko.mozglue.JNIObject;
import org.mozilla.gecko.util.GeckoBundle;
import org.mozilla.gecko.util.ThreadUtils;

@UiThread
public class SessionAccessibility {
  private static final String LOGTAG = "GeckoAccessibility";

  // This is the number BrailleBack uses to start indexing routing keys.
  private static final int BRAILLE_CLICK_BASE_INDEX = -275000000;
  private static final String ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE =
      "ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE";

  @WrapForJNI static final int FLAG_ACCESSIBILITY_FOCUSED = 0;
  @WrapForJNI static final int FLAG_CHECKABLE = 1 << 1;
  @WrapForJNI static final int FLAG_CHECKED = 1 << 2;
  @WrapForJNI static final int FLAG_CLICKABLE = 1 << 3;
  @WrapForJNI static final int FLAG_CONTENT_INVALID = 1 << 4;
  @WrapForJNI static final int FLAG_CONTEXT_CLICKABLE = 1 << 5;
  @WrapForJNI static final int FLAG_EDITABLE = 1 << 6;
  @WrapForJNI static final int FLAG_ENABLED = 1 << 7;
  @WrapForJNI static final int FLAG_FOCUSABLE = 1 << 8;
  @WrapForJNI static final int FLAG_FOCUSED = 1 << 9;
  @WrapForJNI static final int FLAG_LONG_CLICKABLE = 1 << 10;
  @WrapForJNI static final int FLAG_MULTI_LINE = 1 << 11;
  @WrapForJNI static final int FLAG_PASSWORD = 1 << 12;
  @WrapForJNI static final int FLAG_SCROLLABLE = 1 << 13;
  @WrapForJNI static final int FLAG_SELECTED = 1 << 14;
  @WrapForJNI static final int FLAG_VISIBLE_TO_USER = 1 << 15;
  @WrapForJNI static final int FLAG_SELECTABLE = 1 << 16;
  @WrapForJNI static final int FLAG_EXPANDABLE = 1 << 17;
  @WrapForJNI static final int FLAG_EXPANDED = 1 << 18;

  static final int CLASSNAME_UNKNOWN = -1;
  @WrapForJNI static final int CLASSNAME_VIEW = 0;
  @WrapForJNI static final int CLASSNAME_BUTTON = 1;
  @WrapForJNI static final int CLASSNAME_CHECKBOX = 2;
  @WrapForJNI static final int CLASSNAME_DIALOG = 3;
  @WrapForJNI static final int CLASSNAME_EDITTEXT = 4;
  @WrapForJNI static final int CLASSNAME_GRIDVIEW = 5;
  @WrapForJNI static final int CLASSNAME_IMAGE = 6;
  @WrapForJNI static final int CLASSNAME_LISTVIEW = 7;
  @WrapForJNI static final int CLASSNAME_MENUITEM = 8;
  @WrapForJNI static final int CLASSNAME_PROGRESSBAR = 9;
  @WrapForJNI static final int CLASSNAME_RADIOBUTTON = 10;
  @WrapForJNI static final int CLASSNAME_SEEKBAR = 11;
  @WrapForJNI static final int CLASSNAME_SPINNER = 12;
  @WrapForJNI static final int CLASSNAME_TABWIDGET = 13;
  @WrapForJNI static final int CLASSNAME_TOGGLEBUTTON = 14;
  @WrapForJNI static final int CLASSNAME_WEBVIEW = 15;

  private static final String[] CLASSNAMES = {
    "android.view.View",
    "android.widget.Button",
    "android.widget.CheckBox",
    "android.app.Dialog",
    "android.widget.EditText",
    "android.widget.GridView",
    "android.widget.Image",
    "android.widget.ListView",
    "android.view.MenuItem",
    "android.widget.ProgressBar",
    "android.widget.RadioButton",
    "android.widget.SeekBar",
    "android.widget.Spinner",
    "android.widget.TabWidget",
    "android.widget.ToggleButton",
    "android.webkit.WebView"
  };

  @WrapForJNI static final int HTML_GRANULARITY_DEFAULT = -1;
  @WrapForJNI static final int HTML_GRANULARITY_ARTICLE = 0;
  @WrapForJNI static final int HTML_GRANULARITY_BUTTON = 1;
  @WrapForJNI static final int HTML_GRANULARITY_CHECKBOX = 2;
  @WrapForJNI static final int HTML_GRANULARITY_COMBOBOX = 3;
  @WrapForJNI static final int HTML_GRANULARITY_CONTROL = 4;
  @WrapForJNI static final int HTML_GRANULARITY_FOCUSABLE = 5;
  @WrapForJNI static final int HTML_GRANULARITY_FRAME = 6;
  @WrapForJNI static final int HTML_GRANULARITY_GRAPHIC = 7;
  @WrapForJNI static final int HTML_GRANULARITY_H1 = 8;
  @WrapForJNI static final int HTML_GRANULARITY_H2 = 9;
  @WrapForJNI static final int HTML_GRANULARITY_H3 = 10;
  @WrapForJNI static final int HTML_GRANULARITY_H4 = 11;
  @WrapForJNI static final int HTML_GRANULARITY_H5 = 12;
  @WrapForJNI static final int HTML_GRANULARITY_H6 = 13;
  @WrapForJNI static final int HTML_GRANULARITY_HEADING = 14;
  @WrapForJNI static final int HTML_GRANULARITY_LANDMARK = 15;
  @WrapForJNI static final int HTML_GRANULARITY_LINK = 16;
  @WrapForJNI static final int HTML_GRANULARITY_LIST = 17;
  @WrapForJNI static final int HTML_GRANULARITY_LIST_ITEM = 18;
  @WrapForJNI static final int HTML_GRANULARITY_MAIN = 19;
  @WrapForJNI static final int HTML_GRANULARITY_MEDIA = 20;
  @WrapForJNI static final int HTML_GRANULARITY_RADIO = 21;
  @WrapForJNI static final int HTML_GRANULARITY_SECTION = 22;
  @WrapForJNI static final int HTML_GRANULARITY_TABLE = 23;
  @WrapForJNI static final int HTML_GRANULARITY_TEXT_FIELD = 24;
  @WrapForJNI static final int HTML_GRANULARITY_UNVISITED_LINK = 25;
  @WrapForJNI static final int HTML_GRANULARITY_VISITED_LINK = 26;

  private static String[] sHtmlGranularities = {
    "ARTICLE",
    "BUTTON",
    "CHECKBOX",
    "COMBOBOX",
    "CONTROL",
    "FOCUSABLE",
    "FRAME",
    "GRAPHIC",
    "H1",
    "H2",
    "H3",
    "H4",
    "H5",
    "H6",
    "HEADING",
    "LANDMARK",
    "LINK",
    "LIST",
    "LIST_ITEM",
    "MAIN",
    "MEDIA",
    "RADIO",
    "SECTION",
    "TABLE",
    "TEXT_FIELD",
    "UNVISITED_LINK",
    "VISITED_LINK"
  };

  private static String getClassName(final int index) {
    if (index >= 0 && index < CLASSNAMES.length) {
      return CLASSNAMES[index];
    }

    Log.e(LOGTAG, "Index " + index + " our of CLASSNAME bounds.");
    return "android.view.View"; // Fallback class is View
  }

  /* package */ final class NodeProvider extends AccessibilityNodeProvider {
    @Override
    public AccessibilityNodeInfo createAccessibilityNodeInfo(final int virtualDescendantId) {
      AccessibilityNodeInfo node = null;
      if (mAttached) {
        node = getNodeFromGecko(virtualDescendantId);
      }

      if (node == null) {
        Log.w(
            LOGTAG,
            "Failed to retrieve accessible node virtualDescendantId="
                + virtualDescendantId
                + " mAttached="
                + mAttached);
        node = AccessibilityNodeInfo.obtain(mView, View.NO_ID);
        if (Build.VERSION.SDK_INT < 17 || mView.getDisplay() != null) {
          // When running junit tests we don't have a display
          mView.onInitializeAccessibilityNodeInfo(node);
        }
        node.setClassName("android.webkit.WebView");
      }

      return node;
    }

    @Override
    public boolean performAction(
        final int virtualViewId, final int action, final Bundle arguments) {
      final GeckoBundle data;

      switch (action) {
        case AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS:
          sendEvent(
              AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED,
              virtualViewId,
              CLASSNAME_UNKNOWN,
              null);
          return true;
        case AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS:
          sendEvent(
              AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED,
              virtualViewId,
              virtualViewId == View.NO_ID ? CLASSNAME_WEBVIEW : CLASSNAME_UNKNOWN,
              null);
          return true;
        case AccessibilityNodeInfo.ACTION_CLICK:
        case AccessibilityNodeInfo.ACTION_EXPAND:
        case AccessibilityNodeInfo.ACTION_COLLAPSE:
          nativeProvider.click(virtualViewId);
          return true;
        case AccessibilityNodeInfo.ACTION_LONG_CLICK:
          // XXX: Implement long press.
          return true;
        case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD:
          if (virtualViewId == View.NO_ID) {
            // Scroll the viewport forwards by approximately 80%.
            mSession
                .getPanZoomController()
                .scrollBy(
                    ScreenLength.zero(),
                    ScreenLength.fromVisualViewportHeight(0.8),
                    PanZoomController.SCROLL_BEHAVIOR_AUTO);
          } else {
            // XXX: It looks like we never call scroll on virtual views.
            // If we did, we should synthesize a wheel event on it's center coordinate.
          }
          return true;
        case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD:
          if (virtualViewId == View.NO_ID) {
            // Scroll the viewport backwards by approximately 80%.
            mSession
                .getPanZoomController()
                .scrollBy(
                    ScreenLength.zero(),
                    ScreenLength.fromVisualViewportHeight(-0.8),
                    PanZoomController.SCROLL_BEHAVIOR_AUTO);
          } else {
            // XXX: It looks like we never call scroll on virtual views.
            // If we did, we should synthesize a wheel event on it's center coordinate.
          }
          return true;
        case AccessibilityNodeInfo.ACTION_SELECT:
          nativeProvider.click(virtualViewId);
          return true;
        case AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT:
          requestViewFocus();
          return pivot(
              virtualViewId,
              arguments != null
                  ? arguments.getString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING)
                  : "",
              true,
              false);
        case AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT:
          requestViewFocus();
          return pivot(
              virtualViewId,
              arguments != null
                  ? arguments.getString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING)
                  : "",
              false,
              false);
        case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY:
        case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY:
          // XXX: Self brailling gives this action with a bogus argument instead of an actual click
          // action;
          // the argument value is the BRAILLE_CLICK_BASE_INDEX - the index of the routing key that
          // was hit.
          // Other negative values are used by ChromeVox, but we don't support them.
          // FAKE_GRANULARITY_READ_CURRENT = -1
          // FAKE_GRANULARITY_READ_TITLE = -2
          // FAKE_GRANULARITY_STOP_SPEECH = -3
          // FAKE_GRANULARITY_CHANGE_SHIFTER = -4
          if (arguments == null) {
            return false;
          }
          final int granularity =
              arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT);
          if (granularity <= BRAILLE_CLICK_BASE_INDEX) {
            // XXX: Use click offset to update caret position in editables (BRAILLE_CLICK_BASE_INDEX
            // - granularity).
            nativeProvider.click(virtualViewId);
          } else if (granularity > 0) {
            final boolean extendSelection =
                arguments.getBoolean(
                    AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN);
            final boolean next =
                action == AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY;
            return nativeProvider.navigateText(
                virtualViewId, granularity, mStartOffset, mEndOffset, next, extendSelection);
          }
          return true;
        case AccessibilityNodeInfo.ACTION_SET_SELECTION:
          if (arguments == null) {
            return false;
          }
          final int selectionStart =
              arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT);
          final int selectionEnd =
              arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT);
          nativeProvider.setSelection(virtualViewId, selectionStart, selectionEnd);
          return true;
        case AccessibilityNodeInfo.ACTION_CUT:
          nativeProvider.cut(virtualViewId);
          return true;
        case AccessibilityNodeInfo.ACTION_COPY:
          nativeProvider.copy(virtualViewId);
          return true;
        case AccessibilityNodeInfo.ACTION_PASTE:
          nativeProvider.paste(virtualViewId);
          return true;
        case AccessibilityNodeInfo.ACTION_SET_TEXT:
          if (arguments == null) {
            return false;
          }
          final String value =
              arguments.getString(
                  Build.VERSION.SDK_INT >= 21
                      ? AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE
                      : ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE);
          if (mAttached) {
            nativeProvider.setText(virtualViewId, value);
          }
          return true;
      }

      return mView.performAccessibilityAction(action, arguments);
    }

    @Override
    public AccessibilityNodeInfo findFocus(final int focus) {
      switch (focus) {
        case AccessibilityNodeInfo.FOCUS_ACCESSIBILITY:
          if (mAccessibilityFocusedNode != 0) {
            return createAccessibilityNodeInfo(mAccessibilityFocusedNode);
          }
          break;
        case AccessibilityNodeInfo.FOCUS_INPUT:
          if (mFocusedNode != 0) {
            return createAccessibilityNodeInfo(mFocusedNode);
          }
          break;
      }

      return super.findFocus(focus);
    }

    private AccessibilityNodeInfo getNodeFromGecko(final int virtualViewId) {
      ThreadUtils.assertOnUiThread();
      final AccessibilityNodeInfo node = AccessibilityNodeInfo.obtain(mView, virtualViewId);
      nativeProvider.getNodeInfo(virtualViewId, node);

      // We set the bounds in parent here because we need to use the client-to-screen matrix
      // and it is only available in the UI thread.
      final Rect bounds = new Rect();
      node.getBoundsInParent(bounds);

      final Matrix matrix = new Matrix();
      mSession.getClientToScreenMatrix(matrix);
      final float[] origin = new float[2];
      matrix.mapPoints(origin);
      bounds.offset((int) origin[0], (int) origin[1]);
      node.setBoundsInScreen(bounds);

      return node;
    }
  }

  // Gecko session we are proxying
  /* package */ final GeckoSession mSession;
  // This is the view that delegates accessibility to us. We also sends event through it.
  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;
  private boolean mViewFocusRequested = false;

  /* package */ SessionAccessibility(final GeckoSession session) {
    mSession = session;
    Settings.updateAccessibilitySettings();
  }

  /* package */ static void setForceEnabled(final boolean forceEnabled) {
    Settings.setForceEnabled(forceEnabled);
  }

  /**
   * Get the View instance that delegates accessibility to this session.
   *
   * @return View instance.
   */
  public @Nullable View getView() {
    ThreadUtils.assertOnUiThread();

    return mView;
  }

  /**
   * Set the View instance that should delegate accessibility to this session.
   *
   * @param view View instance.
   */
  @UiThread
  public void setView(final @Nullable View view) {
    ThreadUtils.assertOnUiThread();

    if (mView != null) {
      mView.setAccessibilityDelegate(null);
    }

    mView = view;

    if (mView == null) {
      return;
    }

    mView.setAccessibilityDelegate(
        new View.AccessibilityDelegate() {
          private NodeProvider mProvider;

          @Override
          public AccessibilityNodeProvider getAccessibilityNodeProvider(final View hostView) {
            if (hostView != mView) {
              return null;
            }
            if (mProvider == null) {
              mProvider = new NodeProvider();
            }
            return mProvider;
          }

          @Override
          public void sendAccessibilityEvent(final View host, final int eventType) {
            if (eventType == AccessibilityEvent.TYPE_VIEW_FOCUSED) {
              // We rely on the focus events sent from Gecko.
              return;
            }

            super.sendAccessibilityEvent(host, eventType);
          }
        });
  }

  private boolean isInTest() {
    return Build.VERSION.SDK_INT >= 17 && mView != null && mView.getDisplay() == null;
  }

  private void requestViewFocus() {
    if (!mView.isFocused() && !isInTest()) {
      mViewFocusRequested = true;
      mView.requestFocus();
    }
  }

  private static class Settings {
    private static volatile boolean sEnabled;
    private static volatile boolean sTouchExplorationEnabled;
    private static volatile boolean sForceEnabled;

    public static void setForceEnabled(final boolean forceEnabled) {
      sForceEnabled = forceEnabled;
      dispatch();
    }

    static {
      final Context context = GeckoAppShell.getApplicationContext();
      final AccessibilityManager accessibilityManager =
          (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);

      accessibilityManager.addAccessibilityStateChangeListener(
          enabled -> updateAccessibilitySettings());

      if (Build.VERSION.SDK_INT >= 19) {
        accessibilityManager.addTouchExplorationStateChangeListener(
            enabled -> updateAccessibilitySettings());
      }
    }

    public static boolean isEnabled() {
      return sEnabled || sForceEnabled;
    }

    public static boolean isTouchExplorationEnabled() {
      return sTouchExplorationEnabled || sForceEnabled;
    }

    public static void updateAccessibilitySettings() {
      final AccessibilityManager accessibilityManager =
          (AccessibilityManager)
              GeckoAppShell.getApplicationContext().getSystemService(Context.ACCESSIBILITY_SERVICE);
      sEnabled = accessibilityManager.isEnabled();
      sTouchExplorationEnabled = sEnabled && accessibilityManager.isTouchExplorationEnabled();
      dispatch();
    }

    /* package */ static void dispatch() {
      if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
        toggleNativeAccessibility(isEnabled());
      } else {
        GeckoThread.queueNativeCallUntil(
            GeckoThread.State.PROFILE_READY,
            Settings.class,
            "toggleNativeAccessibility",
            isEnabled());
      }
    }

    @WrapForJNI(dispatchTo = "gecko")
    private static native void toggleNativeAccessibility(boolean enable);
  }

  @SuppressWarnings("checkstyle:javadocmethod")
  public boolean onMotionEvent(final @NonNull MotionEvent event) {
    ThreadUtils.assertOnUiThread();

    if (!Settings.isTouchExplorationEnabled()) {
      return false;
    }

    if (event.getSource() != InputDevice.SOURCE_TOUCHSCREEN) {
      return false;
    }

    final int action = event.getActionMasked();
    if ((action != MotionEvent.ACTION_HOVER_MOVE)
        && (action != MotionEvent.ACTION_HOVER_ENTER)
        && (action != MotionEvent.ACTION_HOVER_EXIT)) {
      return false;
    }

    requestViewFocus();

    nativeProvider.exploreByTouch(
        mAccessibilityFocusedNode != 0 ? mAccessibilityFocusedNode : View.NO_ID,
        event.getX(),
        event.getY());

    return true;
  }

  /* package */ void sendEvent(
      final int eventType, final int sourceId, final int className, final GeckoBundle eventData) {
    ThreadUtils.assertOnUiThread();
    if (mView == null || !mAttached) {
      return;
    }

    if (mViewFocusRequested && className == CLASSNAME_WEBVIEW) {
      // If the view was focused from an accessiblity action or
      // explore-by-touch, we supress this focus event to avoid noise.
      mViewFocusRequested = false;
      return;
    }

    final AccessibilityEvent event = AccessibilityEvent.obtain(eventType);
    event.setPackageName(GeckoAppShell.getApplicationContext().getPackageName());
    event.setSource(mView, sourceId);
    event.setEnabled(true);

    int eventClassName = className;
    if (eventClassName == CLASSNAME_UNKNOWN) {
      eventClassName = nativeProvider.getNodeClassName(sourceId);
    }
    event.setClassName(getClassName(eventClassName));

    if (eventData != null) {
      if (eventData.containsKey("text")) {
        event.getText().add(eventData.getString("text"));
      }
      event.setContentDescription(eventData.getString("description", ""));
      event.setAddedCount(eventData.getInt("addedCount", -1));
      event.setRemovedCount(eventData.getInt("removedCount", -1));
      event.setFromIndex(eventData.getInt("fromIndex", -1));
      event.setItemCount(eventData.getInt("itemCount", -1));
      event.setCurrentItemIndex(eventData.getInt("currentItemIndex", -1));
      event.setBeforeText(eventData.getString("beforeText", ""));
      event.setToIndex(eventData.getInt("toIndex", -1));
      event.setScrollX(eventData.getInt("scrollX", -1));
      event.setScrollY(eventData.getInt("scrollY", -1));
      event.setMaxScrollX(eventData.getInt("maxScrollX", -1));
      event.setMaxScrollY(eventData.getInt("maxScrollY", -1));
      event.setChecked((eventData.getInt("flags") & FLAG_CHECKED) != 0);
    }

    // Update stored state from this event.
    switch (eventType) {
      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;
    }

    try {
      ((ViewParent) mView).requestSendAccessibilityEvent(mView, event);
    } catch (final IllegalStateException ex) {
      // Accessibility could be activated in Gecko via xpcom, for example when using a11y
      // devtools. Events that are forwarded to the platform will throw an exception.
    }
  }

  private boolean pivot(
      final int id, final String granularity, final boolean forward, final boolean inclusive) {
    if (!forward && id == View.NO_ID) {
      // If attempting to pivot backwards from the root view, return false.
      return false;
    }

    final int gran = java.util.Arrays.asList(sHtmlGranularities).indexOf(granularity);
    final boolean success = nativeProvider.pivotNative(id, gran, forward, inclusive);
    if (!success && !forward) {
      // If we failed to pivot backwards set the root view as the a11y focus.
      sendEvent(
          AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED, View.NO_ID, CLASSNAME_WEBVIEW, null);
      return true;
    }

    return success;
  }

  /* package */ final class NativeProvider extends JNIObject {
    @WrapForJNI(calledFrom = "ui")
    private void setAttached(final boolean attached) {
      mAttached = attached;
    }

    @Override // JNIObject
    protected void disposeNative() {
      // Disposal happens in native code.
      throw new UnsupportedOperationException();
    }

    @WrapForJNI(dispatchTo = "current")
    public native void getNodeInfo(int id, AccessibilityNodeInfo nodeInfo);

    @WrapForJNI(dispatchTo = "current")
    public native int getNodeClassName(int id);

    @WrapForJNI(dispatchTo = "gecko")
    public native void setText(int id, String text);

    @WrapForJNI(dispatchTo = "gecko")
    public native void click(int id);

    @WrapForJNI(dispatchTo = "current", stubName = "Pivot")
    public native boolean pivotNative(int id, int granularity, boolean forward, boolean inclusive);

    @WrapForJNI(dispatchTo = "gecko")
    public native void exploreByTouch(int id, float x, float y);

    @WrapForJNI(dispatchTo = "current")
    public native boolean navigateText(
        int id, int granularity, int startOffset, int endOffset, boolean forward, boolean select);

    @WrapForJNI(dispatchTo = "gecko")
    public native void setSelection(int id, int start, int end);

    @WrapForJNI(dispatchTo = "gecko")
    public native void cut(int id);

    @WrapForJNI(dispatchTo = "gecko")
    public native void copy(int id);

    @WrapForJNI(dispatchTo = "gecko")
    public native void paste(int id);

    @WrapForJNI(calledFrom = "gecko", stubName = "SendEvent")
    private void sendEventNative(
        final int eventType, final int sourceId, final int className, final GeckoBundle eventData) {
      ThreadUtils.runOnUiThread(
          new Runnable() {
            @Override
            public void run() {
              sendEvent(eventType, sourceId, className, eventData);
            }
          });
    }

    @WrapForJNI
    private void populateNodeInfo(
        final AccessibilityNodeInfo node,
        final int id,
        final int parentId,
        final int[] children,
        final int flags,
        final int className,
        final int[] bounds,
        @Nullable final String text,
        @Nullable final String description,
        @Nullable final String hint,
        @Nullable final String geckoRole,
        @Nullable final String roleDescription,
        @Nullable final String viewIdResourceName,
        final int inputType) {
      if (mView == null) {
        return;
      }

      final boolean isRoot = id == View.NO_ID;
      if (isRoot) {
        if (Build.VERSION.SDK_INT < 17 || mView.getDisplay() != null) {
          // When running junit tests we don't have a display
          mView.onInitializeAccessibilityNodeInfo(node);
        }
        node.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD);
        node.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);
      } else {
        node.setParent(mView, parentId);
      }

      // The basics
      node.setPackageName(GeckoAppShell.getApplicationContext().getPackageName());
      node.setClassName(getClassName(className));

      if (text != null) {
        node.setText(text);
      }

      if (description != null) {
        node.setContentDescription(description);
      }

      // Add actions
      node.addAction(AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT);
      node.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT);
      node.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY);
      node.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY);
      node.setMovementGranularities(
          AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER
              | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD
              | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE
              | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH);
      if ((flags & FLAG_CLICKABLE) != 0) {
        node.addAction(AccessibilityNodeInfo.ACTION_CLICK);
      }

      // Set boolean properties
      node.setCheckable((flags & FLAG_CHECKABLE) != 0);
      node.setChecked((flags & FLAG_CHECKED) != 0);
      node.setClickable((flags & FLAG_CLICKABLE) != 0);
      node.setEnabled((flags & FLAG_ENABLED) != 0);
      node.setFocusable((flags & FLAG_FOCUSABLE) != 0);
      node.setLongClickable((flags & FLAG_LONG_CLICKABLE) != 0);
      node.setPassword((flags & FLAG_PASSWORD) != 0);
      node.setScrollable((flags & FLAG_SCROLLABLE) != 0);
      node.setSelected((flags & FLAG_SELECTED) != 0);
      node.setVisibleToUser((flags & FLAG_VISIBLE_TO_USER) != 0);
      // Other boolean properties to consider later:
      // setHeading, setImportantForAccessibility, setScreenReaderFocusable, setShowingHintText,
      // setDismissable

      if (mAccessibilityFocusedNode == id) {
        node.addAction(AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS);
        node.setAccessibilityFocused(true);
      } else {
        node.addAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS);
      }
      node.setFocused(mFocusedNode == id);

      final Rect parentBounds = new Rect(bounds[0], bounds[1], bounds[2], bounds[3]);
      node.setBoundsInParent(parentBounds);

      for (final int childId : children) {
        node.addChild(mView, childId);
      }

      // SDK 18 and above
      if (Build.VERSION.SDK_INT >= 18) {
        node.setViewIdResourceName(viewIdResourceName);

        if ((flags & FLAG_EDITABLE) != 0) {
          node.addAction(AccessibilityNodeInfo.ACTION_SET_SELECTION);
          node.addAction(AccessibilityNodeInfo.ACTION_CUT);
          node.addAction(AccessibilityNodeInfo.ACTION_COPY);
          node.addAction(AccessibilityNodeInfo.ACTION_PASTE);
          node.setEditable(true);
        }
      }

      // SDK 19 and above
      if (Build.VERSION.SDK_INT >= 19) {
        node.setMultiLine((flags & FLAG_MULTI_LINE) != 0);
        node.setContentInvalid((flags & FLAG_CONTENT_INVALID) != 0);

        // Set bundle keys like role and hint
        final Bundle bundle = node.getExtras();
        if (hint != null) {
          bundle.putCharSequence("AccessibilityNodeInfo.hint", hint);
          if (Build.VERSION.SDK_INT >= 26) {
            node.setHintText(hint);
          }
        }
        if (geckoRole != null) {
          bundle.putCharSequence("AccessibilityNodeInfo.geckoRole", geckoRole);
        }
        if (roleDescription != null) {
          bundle.putCharSequence("AccessibilityNodeInfo.roleDescription", roleDescription);
        }
        if (isRoot) {
          // Argument values for ACTION_NEXT_HTML_ELEMENT/ACTION_PREVIOUS_HTML_ELEMENT.
          // This is mostly here to let TalkBack know we are a legit "WebView".
          bundle.putCharSequence(
              "ACTION_ARGUMENT_HTML_ELEMENT_STRING_VALUES",
              TextUtils.join(",", sHtmlGranularities));
        }

        if (inputType != InputType.TYPE_NULL) {
          node.setInputType(inputType);
        }
      }

      // SDK 21 and above
      if (Build.VERSION.SDK_INT >= 21) {
        if ((flags & FLAG_EXPANDABLE) != 0) {
          if ((flags & FLAG_EXPANDED) != 0) {
            node.removeAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND);
            node.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE);
          } else {
            node.removeAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE);
            node.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND);
          }
        }
      }

      // SDK 23 and above
      if (Build.VERSION.SDK_INT >= 23) {
        node.setContextClickable((flags & FLAG_CONTEXT_CLICKABLE) != 0);
      }
    }

    @WrapForJNI
    private void populateNodeCollectionItemInfo(
        final AccessibilityNodeInfo node,
        final int rowIndex,
        final int rowSpan,
        final int columnIndex,
        final int columnSpan) {
      final CollectionItemInfo collectionItemInfo =
          CollectionItemInfo.obtain(rowIndex, rowSpan, columnIndex, columnSpan, false);
      node.setCollectionItemInfo(collectionItemInfo);
    }

    @WrapForJNI
    private void populateNodeCollectionInfo(
        final AccessibilityNodeInfo node,
        final int rowCount,
        final int columnCount,
        final int selectionMode,
        final boolean isHierarchical) {
      final CollectionInfo collectionInfo =
          Build.VERSION.SDK_INT >= 21
              ? CollectionInfo.obtain(rowCount, columnCount, isHierarchical, selectionMode)
              : CollectionInfo.obtain(rowCount, columnCount, isHierarchical);
      node.setCollectionInfo(collectionInfo);
    }

    @WrapForJNI
    private void populateNodeRangeInfo(
        final AccessibilityNodeInfo node,
        final int rangeType,
        final float min,
        final float max,
        final float current) {
      final RangeInfo rangeInfo = RangeInfo.obtain(rangeType, min, max, current);
      node.setRangeInfo(rangeInfo);
    }
  }
}
