/* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package android.webkit; import android.os.Bundle; import android.provider.Settings; import android.text.TextUtils; import android.text.TextUtils.SimpleStringSplitter; import android.util.Log; import android.view.KeyEvent; import android.view.accessibility.AccessibilityEvent; import android.view.accessibility.AccessibilityManager; import android.view.accessibility.AccessibilityNodeInfo; import android.webkit.WebViewCore.EventHub; import java.util.ArrayList; import java.util.Stack; /** * This class injects accessibility into WebViews with disabled JavaScript or * WebViews with enabled JavaScript but for which we have no accessibility * script to inject. *

* Note: To avoid changes in the framework upon changing the available * navigation axis, or reordering the navigation axis, or changing * the key bindings, or defining sequence of actions to be bound to * a given key this class is navigation axis agnostic. It is only * aware of one navigation axis which is in fact the default behavior * of webViews while using the DPAD/TrackBall. *

* In general a key binding is a mapping from modifiers + key code to * a sequence of actions. For more detail how to specify key bindings refer to * {@link android.provider.Settings.Secure#ACCESSIBILITY_WEB_CONTENT_KEY_BINDINGS}. *

* The possible actions are invocations to * {@link #setCurrentAxis(int, boolean, String)}, or * {@link #traverseCurrentAxis(int, boolean, String)} * {@link #traverseGivenAxis(int, int, boolean, String)} * {@link #performAxisTransition(int, int, boolean, String)} * referred via the values of: * {@link #ACTION_SET_CURRENT_AXIS}, * {@link #ACTION_TRAVERSE_CURRENT_AXIS}, * {@link #ACTION_TRAVERSE_GIVEN_AXIS}, * {@link #ACTION_PERFORM_AXIS_TRANSITION}, * respectively. * The arguments for the action invocation are specified as offset * hexademical pairs. Note the last argument of the invocation * should NOT be specified in the binding as it is provided by * this class. For details about the key binding implementation * refer to {@link AccessibilityWebContentKeyBinding}. */ class AccessibilityInjectorFallback { private static final String LOG_TAG = "AccessibilityInjector"; private static final boolean DEBUG = true; private static final int ACTION_SET_CURRENT_AXIS = 0; private static final int ACTION_TRAVERSE_CURRENT_AXIS = 1; private static final int ACTION_TRAVERSE_GIVEN_AXIS = 2; private static final int ACTION_PERFORM_AXIS_TRANSITION = 3; private static final int ACTION_TRAVERSE_DEFAULT_WEB_VIEW_BEHAVIOR_AXIS = 4; // WebView navigation axes from WebViewCore.h, plus an additional axis for // the default behavior. private static final int NAVIGATION_AXIS_CHARACTER = 0; private static final int NAVIGATION_AXIS_WORD = 1; private static final int NAVIGATION_AXIS_SENTENCE = 2; @SuppressWarnings("unused") private static final int NAVIGATION_AXIS_HEADING = 3; private static final int NAVIGATION_AXIS_SIBLING = 5; @SuppressWarnings("unused") private static final int NAVIGATION_AXIS_PARENT_FIRST_CHILD = 5; private static final int NAVIGATION_AXIS_DOCUMENT = 6; private static final int NAVIGATION_AXIS_DEFAULT_WEB_VIEW_BEHAVIOR = 7; // WebView navigation directions from WebViewCore.h. private static final int NAVIGATION_DIRECTION_BACKWARD = 0; private static final int NAVIGATION_DIRECTION_FORWARD = 1; // these are the same for all instances so make them process wide private static ArrayList sBindings = new ArrayList(); // handle to the WebViewClassic this injector is associated with. private final WebViewClassic mWebView; private final WebView mWebViewInternal; // events scheduled for sending as soon as we receive the selected text private final Stack mScheduledEventStack = new Stack(); // the current traversal axis private int mCurrentAxis = 2; // sentence // we need to consume the up if we have handled the last down private boolean mLastDownEventHandled; // getting two empty selection strings in a row we let the WebView handle the event private boolean mIsLastSelectionStringNull; // keep track of last direction private int mLastDirection; /** * Creates a new injector associated with a given {@link WebViewClassic}. * * @param webView The associated WebViewClassic. */ public AccessibilityInjectorFallback(WebViewClassic webView) { mWebView = webView; mWebViewInternal = mWebView.getWebView(); ensureWebContentKeyBindings(); } /** * Processes a key down event. * * @return True if the event was processed. */ public boolean onKeyEvent(KeyEvent event) { // We do not handle ENTER in any circumstances. if (isEnterActionKey(event.getKeyCode())) { return false; } if (event.getAction() == KeyEvent.ACTION_UP) { return mLastDownEventHandled; } mLastDownEventHandled = false; AccessibilityWebContentKeyBinding binding = null; for (AccessibilityWebContentKeyBinding candidate : sBindings) { if (event.getKeyCode() == candidate.getKeyCode() && event.hasModifiers(candidate.getModifiers())) { binding = candidate; break; } } if (binding == null) { return false; } for (int i = 0, count = binding.getActionCount(); i < count; i++) { int actionCode = binding.getActionCode(i); String contentDescription = Integer.toHexString(binding.getAction(i)); switch (actionCode) { case ACTION_SET_CURRENT_AXIS: int axis = binding.getFirstArgument(i); boolean sendEvent = (binding.getSecondArgument(i) == 1); setCurrentAxis(axis, sendEvent, contentDescription); mLastDownEventHandled = true; break; case ACTION_TRAVERSE_CURRENT_AXIS: int direction = binding.getFirstArgument(i); // on second null selection string in same direction - WebView handles the event if (direction == mLastDirection && mIsLastSelectionStringNull) { mIsLastSelectionStringNull = false; return false; } mLastDirection = direction; sendEvent = (binding.getSecondArgument(i) == 1); mLastDownEventHandled = traverseCurrentAxis(direction, sendEvent, contentDescription); break; case ACTION_TRAVERSE_GIVEN_AXIS: direction = binding.getFirstArgument(i); // on second null selection string in same direction => WebView handle the event if (direction == mLastDirection && mIsLastSelectionStringNull) { mIsLastSelectionStringNull = false; return false; } mLastDirection = direction; axis = binding.getSecondArgument(i); sendEvent = (binding.getThirdArgument(i) == 1); traverseGivenAxis(direction, axis, sendEvent, contentDescription); mLastDownEventHandled = true; break; case ACTION_PERFORM_AXIS_TRANSITION: int fromAxis = binding.getFirstArgument(i); int toAxis = binding.getSecondArgument(i); sendEvent = (binding.getThirdArgument(i) == 1); performAxisTransition(fromAxis, toAxis, sendEvent, contentDescription); mLastDownEventHandled = true; break; case ACTION_TRAVERSE_DEFAULT_WEB_VIEW_BEHAVIOR_AXIS: // This is a special case since we treat the default WebView navigation // behavior as one of the possible navigation axis the user can use. // If we are not on the default WebView navigation axis this is NOP. if (mCurrentAxis == NAVIGATION_AXIS_DEFAULT_WEB_VIEW_BEHAVIOR) { // While WebVew handles navigation we do not get null selection // strings so do not check for that here as the cases above. mLastDirection = binding.getFirstArgument(i); sendEvent = (binding.getSecondArgument(i) == 1); traverseGivenAxis(mLastDirection, NAVIGATION_AXIS_DEFAULT_WEB_VIEW_BEHAVIOR, sendEvent, contentDescription); mLastDownEventHandled = false; } else { mLastDownEventHandled = true; } break; default: Log.w(LOG_TAG, "Unknown action code: " + actionCode); } } return mLastDownEventHandled; } /** * Set the current navigation axis which will be used while * calling {@link #traverseCurrentAxis(int, boolean, String)}. * * @param axis The axis to set. * @param sendEvent Whether to send an accessibility event to * announce the change. */ private void setCurrentAxis(int axis, boolean sendEvent, String contentDescription) { mCurrentAxis = axis; if (sendEvent) { final AccessibilityEvent event = getPartialyPopulatedAccessibilityEvent( AccessibilityEvent.TYPE_ANNOUNCEMENT); event.getText().add(String.valueOf(axis)); event.setContentDescription(contentDescription); sendAccessibilityEvent(event); } } /** * Performs conditional transition one axis to another. * * @param fromAxis The axis which must be the current for the transition to occur. * @param toAxis The axis to which to transition. * @param sendEvent Flag if to send an event to announce successful transition. * @param contentDescription A description of the performed action. */ private void performAxisTransition(int fromAxis, int toAxis, boolean sendEvent, String contentDescription) { if (mCurrentAxis == fromAxis) { setCurrentAxis(toAxis, sendEvent, contentDescription); } } /** * Traverse the document along the current navigation axis. * * @param direction The direction of traversal. * @param sendEvent Whether to send an accessibility event to * announce the change. * @param contentDescription A description of the performed action. * @see #setCurrentAxis(int, boolean, String) */ private boolean traverseCurrentAxis(int direction, boolean sendEvent, String contentDescription) { return traverseGivenAxis(direction, mCurrentAxis, sendEvent, contentDescription); } boolean performAccessibilityAction(int action, Bundle arguments) { switch (action) { case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY: case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY: { final int direction = getDirectionForAction(action); final int axis = getAxisForGranularity(arguments.getInt( AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT)); return traverseGivenAxis(direction, axis, true, null); } case AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT: case AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT: { final int direction = getDirectionForAction(action); // TODO: Add support for moving by object. final int axis = NAVIGATION_AXIS_SENTENCE; return traverseGivenAxis(direction, axis, true, null); } default: return false; } } /** * Returns the {@link WebView}-defined direction for the given * {@link AccessibilityNodeInfo}-defined action. * * @param action An accessibility action identifier. * @return A web view navigation direction. */ private static int getDirectionForAction(int action) { switch (action) { case AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT: case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY: return NAVIGATION_DIRECTION_FORWARD; case AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT: case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY: return NAVIGATION_DIRECTION_BACKWARD; default: return -1; } } /** * Returns the {@link WebView}-defined axis for the given * {@link AccessibilityNodeInfo}-defined granularity. * * @param granularity An accessibility granularity identifier. * @return A web view navigation axis. */ private static int getAxisForGranularity(int granularity) { switch (granularity) { case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER: return NAVIGATION_AXIS_CHARACTER; case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD: return NAVIGATION_AXIS_WORD; case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE: return NAVIGATION_AXIS_SENTENCE; case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH: // TODO: This should map to object once we implement it. return NAVIGATION_AXIS_SENTENCE; case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PAGE: return NAVIGATION_AXIS_DOCUMENT; default: return -1; } } /** * Traverse the document along the given navigation axis. * * @param direction The direction of traversal. * @param axis The axis along which to traverse. * @param sendEvent Whether to send an accessibility event to * announce the change. * @param contentDescription A description of the performed action. */ private boolean traverseGivenAxis(int direction, int axis, boolean sendEvent, String contentDescription) { WebViewCore webViewCore = mWebView.getWebViewCore(); if (webViewCore == null) { return false; } AccessibilityEvent event = null; if (sendEvent) { event = getPartialyPopulatedAccessibilityEvent( AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY); // the text will be set upon receiving the selection string event.setContentDescription(contentDescription); } mScheduledEventStack.push(event); // if the axis is the default let WebView handle the event which will // result in cursor ring movement and selection of its content if (axis == NAVIGATION_AXIS_DEFAULT_WEB_VIEW_BEHAVIOR) { return false; } webViewCore.sendMessage(EventHub.MODIFY_SELECTION, direction, axis); return true; } /** * Called when the selectionString has changed. */ public void onSelectionStringChange(String selectionString) { if (DEBUG) { Log.d(LOG_TAG, "Selection string: " + selectionString); } mIsLastSelectionStringNull = (selectionString == null); if (mScheduledEventStack.isEmpty()) { return; } AccessibilityEvent event = mScheduledEventStack.pop(); if ((event != null) && (selectionString != null)) { event.getText().add(selectionString); event.setFromIndex(0); event.setToIndex(selectionString.length()); sendAccessibilityEvent(event); } } /** * Sends an {@link AccessibilityEvent}. * * @param event The event to send. */ private void sendAccessibilityEvent(AccessibilityEvent event) { if (DEBUG) { Log.d(LOG_TAG, "Dispatching: " + event); } // accessibility may be disabled while waiting for the selection string AccessibilityManager accessibilityManager = AccessibilityManager.getInstance(mWebView.getContext()); if (accessibilityManager.isEnabled()) { accessibilityManager.sendAccessibilityEvent(event); } } /** * @return An accessibility event whose members are populated except its * text and content description. */ private AccessibilityEvent getPartialyPopulatedAccessibilityEvent(int eventType) { AccessibilityEvent event = AccessibilityEvent.obtain(eventType); mWebViewInternal.onInitializeAccessibilityEvent(event); return event; } /** * Ensures that the Web content key bindings are loaded. */ private void ensureWebContentKeyBindings() { if (sBindings.size() > 0) { return; } String webContentKeyBindingsString = Settings.Secure.getString( mWebView.getContext().getContentResolver(), Settings.Secure.ACCESSIBILITY_WEB_CONTENT_KEY_BINDINGS); SimpleStringSplitter semiColonSplitter = new SimpleStringSplitter(';'); semiColonSplitter.setString(webContentKeyBindingsString); while (semiColonSplitter.hasNext()) { String bindingString = semiColonSplitter.next(); if (TextUtils.isEmpty(bindingString)) { Log.e(LOG_TAG, "Disregarding malformed Web content key binding: " + webContentKeyBindingsString); continue; } String[] keyValueArray = bindingString.split("="); if (keyValueArray.length != 2) { Log.e(LOG_TAG, "Disregarding malformed Web content key binding: " + bindingString); continue; } try { long keyCodeAndModifiers = Long.decode(keyValueArray[0].trim()); String[] actionStrings = keyValueArray[1].split(":"); int[] actions = new int[actionStrings.length]; for (int i = 0, count = actions.length; i < count; i++) { actions[i] = Integer.decode(actionStrings[i].trim()); } sBindings.add(new AccessibilityWebContentKeyBinding(keyCodeAndModifiers, actions)); } catch (NumberFormatException nfe) { Log.e(LOG_TAG, "Disregarding malformed key binding: " + bindingString); } } } private boolean isEnterActionKey(int keyCode) { return keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER; } /** * Represents a web content key-binding. */ private static final class AccessibilityWebContentKeyBinding { private static final int MODIFIERS_OFFSET = 32; private static final long MODIFIERS_MASK = 0xFFFFFFF00000000L; private static final int KEY_CODE_OFFSET = 0; private static final long KEY_CODE_MASK = 0x00000000FFFFFFFFL; private static final int ACTION_OFFSET = 24; private static final int ACTION_MASK = 0xFF000000; private static final int FIRST_ARGUMENT_OFFSET = 16; private static final int FIRST_ARGUMENT_MASK = 0x00FF0000; private static final int SECOND_ARGUMENT_OFFSET = 8; private static final int SECOND_ARGUMENT_MASK = 0x0000FF00; private static final int THIRD_ARGUMENT_OFFSET = 0; private static final int THIRD_ARGUMENT_MASK = 0x000000FF; private final long mKeyCodeAndModifiers; private final int [] mActionSequence; /** * @return The key code of the binding key. */ public int getKeyCode() { return (int) ((mKeyCodeAndModifiers & KEY_CODE_MASK) >> KEY_CODE_OFFSET); } /** * @return The meta state of the binding key. */ public int getModifiers() { return (int) ((mKeyCodeAndModifiers & MODIFIERS_MASK) >> MODIFIERS_OFFSET); } /** * @return The number of actions in the key binding. */ public int getActionCount() { return mActionSequence.length; } /** * @param index The action for a given action index. */ public int getAction(int index) { return mActionSequence[index]; } /** * @param index The action code for a given action index. */ public int getActionCode(int index) { return (mActionSequence[index] & ACTION_MASK) >> ACTION_OFFSET; } /** * @param index The first argument for a given action index. */ public int getFirstArgument(int index) { return (mActionSequence[index] & FIRST_ARGUMENT_MASK) >> FIRST_ARGUMENT_OFFSET; } /** * @param index The second argument for a given action index. */ public int getSecondArgument(int index) { return (mActionSequence[index] & SECOND_ARGUMENT_MASK) >> SECOND_ARGUMENT_OFFSET; } /** * @param index The third argument for a given action index. */ public int getThirdArgument(int index) { return (mActionSequence[index] & THIRD_ARGUMENT_MASK) >> THIRD_ARGUMENT_OFFSET; } /** * Creates a new instance. * @param keyCodeAndModifiers The key for the binding (key and modifiers). * @param actionSequence The sequence of action for the binding. */ public AccessibilityWebContentKeyBinding(long keyCodeAndModifiers, int[] actionSequence) { mKeyCodeAndModifiers = keyCodeAndModifiers; mActionSequence = actionSequence; } @Override public String toString() { StringBuilder builder = new StringBuilder(); builder.append("modifiers: "); builder.append(getModifiers()); builder.append(", keyCode: "); builder.append(getKeyCode()); builder.append(", actions["); for (int i = 0, count = getActionCount(); i < count; i++) { builder.append("{actionCode"); builder.append(i); builder.append(": "); builder.append(getActionCode(i)); builder.append(", firstArgument: "); builder.append(getFirstArgument(i)); builder.append(", secondArgument: "); builder.append(getSecondArgument(i)); builder.append(", thirdArgument: "); builder.append(getThirdArgument(i)); builder.append("}"); } builder.append("]"); return builder.toString(); } } }