diff options
Diffstat (limited to 'mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java')
-rw-r--r-- | mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java | 936 |
1 files changed, 936 insertions, 0 deletions
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java new file mode 100644 index 0000000000..e8a50d71b6 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java @@ -0,0 +1,936 @@ +/* -*- 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; + // We must return false if we're already at the edge. + if (next) { + if (mAtEndOfText) { + return false; + } + if (granularity == AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD && mAtLastWord) { + return false; + } + } else if (mAtStartOfText) { + return false; + } + 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 mAtStartOfText = false; + private boolean mAtEndOfText = false; + private boolean mAtLastWord = false; + 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; + mAtStartOfText = false; + mAtEndOfText = false; + mAtLastWord = false; + 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(); + // We must synchronously return false for text navigation + // actions if the user attempts to navigate past the edge. + // Because we do navigation async, we can't query this + // on demand when the action is performed. Therefore, we cache + // whether we're at either edge here. + mAtStartOfText = mStartOffset == 0; + final CharSequence text = event.getText().get(0); + mAtEndOfText = mEndOffset >= text.length(); + mAtLastWord = mAtEndOfText; + if (!mAtLastWord) { + // Words exclude trailing spaces. To figure out whether + // we're at the last word, we need to get the text after + // our end offset and check if it's just spaces. + final CharSequence afterText = text.subSequence(mEndOffset, text.length()); + if (TextUtils.getTrimmedLength(afterText) == 0) { + mAtLastWord = true; + } + } + 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 = "gecko") + public native void 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); + } + } +} |