1/*
2 * Copyright (C) 2010 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package android.webkit;
18
19import android.os.Bundle;
20import android.provider.Settings;
21import android.text.TextUtils;
22import android.text.TextUtils.SimpleStringSplitter;
23import android.util.Log;
24import android.view.KeyEvent;
25import android.view.accessibility.AccessibilityEvent;
26import android.view.accessibility.AccessibilityManager;
27import android.view.accessibility.AccessibilityNodeInfo;
28import android.webkit.WebViewCore.EventHub;
29
30import java.util.ArrayList;
31import java.util.Stack;
32
33/**
34 * This class injects accessibility into WebViews with disabled JavaScript or
35 * WebViews with enabled JavaScript but for which we have no accessibility
36 * script to inject.
37 * </p>
38 * Note: To avoid changes in the framework upon changing the available
39 *       navigation axis, or reordering the navigation axis, or changing
40 *       the key bindings, or defining sequence of actions to be bound to
41 *       a given key this class is navigation axis agnostic. It is only
42 *       aware of one navigation axis which is in fact the default behavior
43 *       of webViews while using the DPAD/TrackBall.
44 * </p>
45 * In general a key binding is a mapping from modifiers + key code to
46 * a sequence of actions. For more detail how to specify key bindings refer to
47 * {@link android.provider.Settings.Secure#ACCESSIBILITY_WEB_CONTENT_KEY_BINDINGS}.
48 * </p>
49 * The possible actions are invocations to
50 * {@link #setCurrentAxis(int, boolean, String)}, or
51 * {@link #traverseCurrentAxis(int, boolean, String)}
52 * {@link #traverseGivenAxis(int, int, boolean, String)}
53 * {@link #performAxisTransition(int, int, boolean, String)}
54 * referred via the values of:
55 * {@link #ACTION_SET_CURRENT_AXIS},
56 * {@link #ACTION_TRAVERSE_CURRENT_AXIS},
57 * {@link #ACTION_TRAVERSE_GIVEN_AXIS},
58 * {@link #ACTION_PERFORM_AXIS_TRANSITION},
59 * respectively.
60 * The arguments for the action invocation are specified as offset
61 * hexademical pairs. Note the last argument of the invocation
62 * should NOT be specified in the binding as it is provided by
63 * this class. For details about the key binding implementation
64 * refer to {@link AccessibilityWebContentKeyBinding}.
65 */
66class AccessibilityInjectorFallback {
67    private static final String LOG_TAG = "AccessibilityInjector";
68
69    private static final boolean DEBUG = true;
70
71    private static final int ACTION_SET_CURRENT_AXIS = 0;
72    private static final int ACTION_TRAVERSE_CURRENT_AXIS = 1;
73    private static final int ACTION_TRAVERSE_GIVEN_AXIS = 2;
74    private static final int ACTION_PERFORM_AXIS_TRANSITION = 3;
75    private static final int ACTION_TRAVERSE_DEFAULT_WEB_VIEW_BEHAVIOR_AXIS = 4;
76
77    // WebView navigation axes from WebViewCore.h, plus an additional axis for
78    // the default behavior.
79    private static final int NAVIGATION_AXIS_CHARACTER = 0;
80    private static final int NAVIGATION_AXIS_WORD = 1;
81    private static final int NAVIGATION_AXIS_SENTENCE = 2;
82    @SuppressWarnings("unused")
83    private static final int NAVIGATION_AXIS_HEADING = 3;
84    private static final int NAVIGATION_AXIS_SIBLING = 5;
85    @SuppressWarnings("unused")
86    private static final int NAVIGATION_AXIS_PARENT_FIRST_CHILD = 5;
87    private static final int NAVIGATION_AXIS_DOCUMENT = 6;
88    private static final int NAVIGATION_AXIS_DEFAULT_WEB_VIEW_BEHAVIOR = 7;
89
90    // WebView navigation directions from WebViewCore.h.
91    private static final int NAVIGATION_DIRECTION_BACKWARD = 0;
92    private static final int NAVIGATION_DIRECTION_FORWARD = 1;
93
94    // these are the same for all instances so make them process wide
95    private static ArrayList<AccessibilityWebContentKeyBinding> sBindings =
96        new ArrayList<AccessibilityWebContentKeyBinding>();
97
98    // handle to the WebViewClassic this injector is associated with.
99    private final WebViewClassic mWebView;
100    private final WebView mWebViewInternal;
101
102    // events scheduled for sending as soon as we receive the selected text
103    private final Stack<AccessibilityEvent> mScheduledEventStack = new Stack<AccessibilityEvent>();
104
105    // the current traversal axis
106    private int mCurrentAxis = 2; // sentence
107
108    // we need to consume the up if we have handled the last down
109    private boolean mLastDownEventHandled;
110
111    // getting two empty selection strings in a row we let the WebView handle the event
112    private boolean mIsLastSelectionStringNull;
113
114    // keep track of last direction
115    private int mLastDirection;
116
117    /**
118     * Creates a new injector associated with a given {@link WebViewClassic}.
119     *
120     * @param webView The associated WebViewClassic.
121     */
122    public AccessibilityInjectorFallback(WebViewClassic webView) {
123        mWebView = webView;
124        mWebViewInternal = mWebView.getWebView();
125        ensureWebContentKeyBindings();
126    }
127
128    /**
129     * Processes a key down <code>event</code>.
130     *
131     * @return True if the event was processed.
132     */
133    public boolean onKeyEvent(KeyEvent event) {
134        // We do not handle ENTER in any circumstances.
135        if (isEnterActionKey(event.getKeyCode())) {
136            return false;
137        }
138
139        if (event.getAction() == KeyEvent.ACTION_UP) {
140            return mLastDownEventHandled;
141        }
142
143        mLastDownEventHandled = false;
144
145        AccessibilityWebContentKeyBinding binding = null;
146        for (AccessibilityWebContentKeyBinding candidate : sBindings) {
147            if (event.getKeyCode() == candidate.getKeyCode()
148                    && event.hasModifiers(candidate.getModifiers())) {
149                binding = candidate;
150                break;
151            }
152        }
153
154        if (binding == null) {
155            return false;
156        }
157
158        for (int i = 0, count = binding.getActionCount(); i < count; i++) {
159            int actionCode = binding.getActionCode(i);
160            String contentDescription = Integer.toHexString(binding.getAction(i));
161            switch (actionCode) {
162                case ACTION_SET_CURRENT_AXIS:
163                    int axis = binding.getFirstArgument(i);
164                    boolean sendEvent = (binding.getSecondArgument(i) == 1);
165                    setCurrentAxis(axis, sendEvent, contentDescription);
166                    mLastDownEventHandled = true;
167                    break;
168                case ACTION_TRAVERSE_CURRENT_AXIS:
169                    int direction = binding.getFirstArgument(i);
170                    // on second null selection string in same direction - WebView handles the event
171                    if (direction == mLastDirection && mIsLastSelectionStringNull) {
172                        mIsLastSelectionStringNull = false;
173                        return false;
174                    }
175                    mLastDirection = direction;
176                    sendEvent = (binding.getSecondArgument(i) == 1);
177                    mLastDownEventHandled = traverseCurrentAxis(direction, sendEvent,
178                            contentDescription);
179                    break;
180                case ACTION_TRAVERSE_GIVEN_AXIS:
181                    direction = binding.getFirstArgument(i);
182                    // on second null selection string in same direction => WebView handle the event
183                    if (direction == mLastDirection && mIsLastSelectionStringNull) {
184                        mIsLastSelectionStringNull = false;
185                        return false;
186                    }
187                    mLastDirection = direction;
188                    axis =  binding.getSecondArgument(i);
189                    sendEvent = (binding.getThirdArgument(i) == 1);
190                    traverseGivenAxis(direction, axis, sendEvent, contentDescription);
191                    mLastDownEventHandled = true;
192                    break;
193                case ACTION_PERFORM_AXIS_TRANSITION:
194                    int fromAxis = binding.getFirstArgument(i);
195                    int toAxis = binding.getSecondArgument(i);
196                    sendEvent = (binding.getThirdArgument(i) == 1);
197                    performAxisTransition(fromAxis, toAxis, sendEvent, contentDescription);
198                    mLastDownEventHandled = true;
199                    break;
200                case ACTION_TRAVERSE_DEFAULT_WEB_VIEW_BEHAVIOR_AXIS:
201                    // This is a special case since we treat the default WebView navigation
202                    // behavior as one of the possible navigation axis the user can use.
203                    // If we are not on the default WebView navigation axis this is NOP.
204                    if (mCurrentAxis == NAVIGATION_AXIS_DEFAULT_WEB_VIEW_BEHAVIOR) {
205                        // While WebVew handles navigation we do not get null selection
206                        // strings so do not check for that here as the cases above.
207                        mLastDirection = binding.getFirstArgument(i);
208                        sendEvent = (binding.getSecondArgument(i) == 1);
209                        traverseGivenAxis(mLastDirection, NAVIGATION_AXIS_DEFAULT_WEB_VIEW_BEHAVIOR,
210                            sendEvent, contentDescription);
211                        mLastDownEventHandled = false;
212                    } else {
213                        mLastDownEventHandled = true;
214                    }
215                    break;
216                default:
217                    Log.w(LOG_TAG, "Unknown action code: " + actionCode);
218            }
219        }
220
221        return mLastDownEventHandled;
222    }
223
224    /**
225     * Set the current navigation axis which will be used while
226     * calling {@link #traverseCurrentAxis(int, boolean, String)}.
227     *
228     * @param axis The axis to set.
229     * @param sendEvent Whether to send an accessibility event to
230     *        announce the change.
231     */
232    private void setCurrentAxis(int axis, boolean sendEvent, String contentDescription) {
233        mCurrentAxis = axis;
234        if (sendEvent) {
235            final AccessibilityEvent event = getPartialyPopulatedAccessibilityEvent(
236                    AccessibilityEvent.TYPE_ANNOUNCEMENT);
237            event.getText().add(String.valueOf(axis));
238            event.setContentDescription(contentDescription);
239            sendAccessibilityEvent(event);
240        }
241    }
242
243    /**
244     * Performs conditional transition one axis to another.
245     *
246     * @param fromAxis The axis which must be the current for the transition to occur.
247     * @param toAxis The axis to which to transition.
248     * @param sendEvent Flag if to send an event to announce successful transition.
249     * @param contentDescription A description of the performed action.
250     */
251    private void performAxisTransition(int fromAxis, int toAxis, boolean sendEvent,
252            String contentDescription) {
253        if (mCurrentAxis == fromAxis) {
254            setCurrentAxis(toAxis, sendEvent, contentDescription);
255        }
256    }
257
258    /**
259     * Traverse the document along the current navigation axis.
260     *
261     * @param direction The direction of traversal.
262     * @param sendEvent Whether to send an accessibility event to
263     *        announce the change.
264     * @param contentDescription A description of the performed action.
265     * @see #setCurrentAxis(int, boolean, String)
266     */
267    private boolean traverseCurrentAxis(int direction, boolean sendEvent,
268            String contentDescription) {
269        return traverseGivenAxis(direction, mCurrentAxis, sendEvent, contentDescription);
270    }
271
272    boolean performAccessibilityAction(int action, Bundle arguments) {
273        switch (action) {
274            case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY:
275            case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY: {
276                final int direction = getDirectionForAction(action);
277                final int axis = getAxisForGranularity(arguments.getInt(
278                        AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT));
279                return traverseGivenAxis(direction, axis, true, null);
280            }
281            case AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT:
282            case AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT: {
283                final int direction = getDirectionForAction(action);
284                // TODO: Add support for moving by object.
285                final int axis = NAVIGATION_AXIS_SENTENCE;
286                return traverseGivenAxis(direction, axis, true, null);
287            }
288            default:
289                return false;
290        }
291    }
292
293    /**
294     * Returns the {@link WebView}-defined direction for the given
295     * {@link AccessibilityNodeInfo}-defined action.
296     *
297     * @param action An accessibility action identifier.
298     * @return A web view navigation direction.
299     */
300    private static int getDirectionForAction(int action) {
301        switch (action) {
302            case AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT:
303            case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY:
304                return NAVIGATION_DIRECTION_FORWARD;
305            case AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT:
306            case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY:
307                return NAVIGATION_DIRECTION_BACKWARD;
308            default:
309                return -1;
310        }
311    }
312
313    /**
314     * Returns the {@link WebView}-defined axis for the given
315     * {@link AccessibilityNodeInfo}-defined granularity.
316     *
317     * @param granularity An accessibility granularity identifier.
318     * @return A web view navigation axis.
319     */
320    private static int getAxisForGranularity(int granularity) {
321        switch (granularity) {
322            case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER:
323                return NAVIGATION_AXIS_CHARACTER;
324            case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD:
325                return NAVIGATION_AXIS_WORD;
326            case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE:
327                return NAVIGATION_AXIS_SENTENCE;
328            case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH:
329                // TODO: This should map to object once we implement it.
330                return NAVIGATION_AXIS_SENTENCE;
331            case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PAGE:
332                return NAVIGATION_AXIS_DOCUMENT;
333            default:
334                return -1;
335        }
336    }
337
338    /**
339     * Traverse the document along the given navigation axis.
340     *
341     * @param direction The direction of traversal.
342     * @param axis The axis along which to traverse.
343     * @param sendEvent Whether to send an accessibility event to
344     *        announce the change.
345     * @param contentDescription A description of the performed action.
346     */
347    private boolean traverseGivenAxis(int direction, int axis, boolean sendEvent,
348            String contentDescription) {
349        WebViewCore webViewCore = mWebView.getWebViewCore();
350        if (webViewCore == null) {
351            return false;
352        }
353
354        AccessibilityEvent event = null;
355        if (sendEvent) {
356            event = getPartialyPopulatedAccessibilityEvent(
357                    AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY);
358            // the text will be set upon receiving the selection string
359            event.setContentDescription(contentDescription);
360        }
361        mScheduledEventStack.push(event);
362
363        // if the axis is the default let WebView handle the event which will
364        // result in cursor ring movement and selection of its content
365        if (axis == NAVIGATION_AXIS_DEFAULT_WEB_VIEW_BEHAVIOR) {
366            return false;
367        }
368
369        webViewCore.sendMessage(EventHub.MODIFY_SELECTION, direction, axis);
370        return true;
371    }
372
373    /**
374     * Called when the <code>selectionString</code> has changed.
375     */
376    public void onSelectionStringChange(String selectionString) {
377        if (DEBUG) {
378            Log.d(LOG_TAG, "Selection string: " + selectionString);
379        }
380        mIsLastSelectionStringNull = (selectionString == null);
381        if (mScheduledEventStack.isEmpty()) {
382            return;
383        }
384        AccessibilityEvent event = mScheduledEventStack.pop();
385        if ((event != null) && (selectionString != null)) {
386            event.getText().add(selectionString);
387            event.setFromIndex(0);
388            event.setToIndex(selectionString.length());
389            sendAccessibilityEvent(event);
390        }
391    }
392
393    /**
394     * Sends an {@link AccessibilityEvent}.
395     *
396     * @param event The event to send.
397     */
398    private void sendAccessibilityEvent(AccessibilityEvent event) {
399        if (DEBUG) {
400            Log.d(LOG_TAG, "Dispatching: " + event);
401        }
402        // accessibility may be disabled while waiting for the selection string
403        AccessibilityManager accessibilityManager =
404            AccessibilityManager.getInstance(mWebView.getContext());
405        if (accessibilityManager.isEnabled()) {
406            accessibilityManager.sendAccessibilityEvent(event);
407        }
408    }
409
410    /**
411     * @return An accessibility event whose members are populated except its
412     *         text and content description.
413     */
414    private AccessibilityEvent getPartialyPopulatedAccessibilityEvent(int eventType) {
415        AccessibilityEvent event = AccessibilityEvent.obtain(eventType);
416        mWebViewInternal.onInitializeAccessibilityEvent(event);
417        return event;
418    }
419
420    /**
421     * Ensures that the Web content key bindings are loaded.
422     */
423    private void ensureWebContentKeyBindings() {
424        if (sBindings.size() > 0) {
425            return;
426        }
427
428        String webContentKeyBindingsString  = Settings.Secure.getString(
429                mWebView.getContext().getContentResolver(),
430                Settings.Secure.ACCESSIBILITY_WEB_CONTENT_KEY_BINDINGS);
431
432        SimpleStringSplitter semiColonSplitter = new SimpleStringSplitter(';');
433        semiColonSplitter.setString(webContentKeyBindingsString);
434
435        while (semiColonSplitter.hasNext()) {
436            String bindingString = semiColonSplitter.next();
437            if (TextUtils.isEmpty(bindingString)) {
438                Log.e(LOG_TAG, "Disregarding malformed Web content key binding: "
439                        + webContentKeyBindingsString);
440                continue;
441            }
442            String[] keyValueArray = bindingString.split("=");
443            if (keyValueArray.length != 2) {
444                Log.e(LOG_TAG, "Disregarding malformed Web content key binding: " + bindingString);
445                continue;
446            }
447            try {
448                long keyCodeAndModifiers = Long.decode(keyValueArray[0].trim());
449                String[] actionStrings = keyValueArray[1].split(":");
450                int[] actions = new int[actionStrings.length];
451                for (int i = 0, count = actions.length; i < count; i++) {
452                    actions[i] = Integer.decode(actionStrings[i].trim());
453                }
454                sBindings.add(new AccessibilityWebContentKeyBinding(keyCodeAndModifiers, actions));
455            } catch (NumberFormatException nfe) {
456                Log.e(LOG_TAG, "Disregarding malformed key binding: " + bindingString);
457            }
458        }
459    }
460
461    private boolean isEnterActionKey(int keyCode) {
462        return keyCode == KeyEvent.KEYCODE_DPAD_CENTER
463                || keyCode == KeyEvent.KEYCODE_ENTER
464                || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER;
465    }
466
467    /**
468     * Represents a web content key-binding.
469     */
470    private static final class AccessibilityWebContentKeyBinding {
471
472        private static final int MODIFIERS_OFFSET = 32;
473        private static final long MODIFIERS_MASK = 0xFFFFFFF00000000L;
474
475        private static final int KEY_CODE_OFFSET = 0;
476        private static final long KEY_CODE_MASK = 0x00000000FFFFFFFFL;
477
478        private static final int ACTION_OFFSET = 24;
479        private static final int ACTION_MASK = 0xFF000000;
480
481        private static final int FIRST_ARGUMENT_OFFSET = 16;
482        private static final int FIRST_ARGUMENT_MASK = 0x00FF0000;
483
484        private static final int SECOND_ARGUMENT_OFFSET = 8;
485        private static final int SECOND_ARGUMENT_MASK = 0x0000FF00;
486
487        private static final int THIRD_ARGUMENT_OFFSET = 0;
488        private static final int THIRD_ARGUMENT_MASK = 0x000000FF;
489
490        private final long mKeyCodeAndModifiers;
491
492        private final int [] mActionSequence;
493
494        /**
495         * @return The key code of the binding key.
496         */
497        public int getKeyCode() {
498            return (int) ((mKeyCodeAndModifiers & KEY_CODE_MASK) >> KEY_CODE_OFFSET);
499        }
500
501        /**
502         * @return The meta state of the binding key.
503         */
504        public int getModifiers() {
505            return (int) ((mKeyCodeAndModifiers & MODIFIERS_MASK) >> MODIFIERS_OFFSET);
506        }
507
508        /**
509         * @return The number of actions in the key binding.
510         */
511        public int getActionCount() {
512            return mActionSequence.length;
513        }
514
515        /**
516         * @param index The action for a given action <code>index</code>.
517         */
518        public int getAction(int index) {
519            return mActionSequence[index];
520        }
521
522        /**
523         * @param index The action code for a given action <code>index</code>.
524         */
525        public int getActionCode(int index) {
526            return (mActionSequence[index] & ACTION_MASK) >> ACTION_OFFSET;
527        }
528
529        /**
530         * @param index The first argument for a given action <code>index</code>.
531         */
532        public int getFirstArgument(int index) {
533            return (mActionSequence[index] & FIRST_ARGUMENT_MASK) >> FIRST_ARGUMENT_OFFSET;
534        }
535
536        /**
537         * @param index The second argument for a given action <code>index</code>.
538         */
539        public int getSecondArgument(int index) {
540            return (mActionSequence[index] & SECOND_ARGUMENT_MASK) >> SECOND_ARGUMENT_OFFSET;
541        }
542
543        /**
544         * @param index The third argument for a given action <code>index</code>.
545         */
546        public int getThirdArgument(int index) {
547            return (mActionSequence[index] & THIRD_ARGUMENT_MASK) >> THIRD_ARGUMENT_OFFSET;
548        }
549
550        /**
551         * Creates a new instance.
552         * @param keyCodeAndModifiers The key for the binding (key and modifiers).
553         * @param actionSequence The sequence of action for the binding.
554         */
555        public AccessibilityWebContentKeyBinding(long keyCodeAndModifiers, int[] actionSequence) {
556            mKeyCodeAndModifiers = keyCodeAndModifiers;
557            mActionSequence = actionSequence;
558        }
559
560        @Override
561        public String toString() {
562            StringBuilder builder = new StringBuilder();
563            builder.append("modifiers: ");
564            builder.append(getModifiers());
565            builder.append(", keyCode: ");
566            builder.append(getKeyCode());
567            builder.append(", actions[");
568            for (int i = 0, count = getActionCount(); i < count; i++) {
569                builder.append("{actionCode");
570                builder.append(i);
571                builder.append(": ");
572                builder.append(getActionCode(i));
573                builder.append(", firstArgument: ");
574                builder.append(getFirstArgument(i));
575                builder.append(", secondArgument: ");
576                builder.append(getSecondArgument(i));
577                builder.append(", thirdArgument: ");
578                builder.append(getThirdArgument(i));
579                builder.append("}");
580            }
581            builder.append("]");
582            return builder.toString();
583        }
584    }
585}
586