Home | History | Annotate | Download | only in webkit
      1 /*
      2  * Copyright (C) 2012 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 
     17 package android.webkit;
     18 
     19 import android.content.Context;
     20 import android.os.Bundle;
     21 import android.os.SystemClock;
     22 import android.provider.Settings;
     23 import android.speech.tts.TextToSpeech;
     24 import android.view.KeyEvent;
     25 import android.view.View;
     26 import android.view.accessibility.AccessibilityManager;
     27 import android.view.accessibility.AccessibilityNodeInfo;
     28 import android.webkit.WebViewCore.EventHub;
     29 
     30 import org.apache.http.NameValuePair;
     31 import org.apache.http.client.utils.URLEncodedUtils;
     32 import org.json.JSONException;
     33 import org.json.JSONObject;
     34 
     35 import java.net.URI;
     36 import java.net.URISyntaxException;
     37 import java.util.Iterator;
     38 import java.util.List;
     39 import java.util.concurrent.atomic.AtomicInteger;
     40 
     41 /**
     42  * Handles injecting accessibility JavaScript and related JavaScript -> Java
     43  * APIs.
     44  */
     45 class AccessibilityInjector {
     46     // The WebViewClassic this injector is responsible for managing.
     47     private final WebViewClassic mWebViewClassic;
     48 
     49     // Cached reference to mWebViewClassic.getContext(), for convenience.
     50     private final Context mContext;
     51 
     52     // Cached reference to mWebViewClassic.getWebView(), for convenience.
     53     private final WebView mWebView;
     54 
     55     // The Java objects that are exposed to JavaScript.
     56     private TextToSpeech mTextToSpeech;
     57     private CallbackHandler mCallback;
     58 
     59     // Lazily loaded helper objects.
     60     private AccessibilityManager mAccessibilityManager;
     61     private AccessibilityInjectorFallback mAccessibilityInjectorFallback;
     62     private JSONObject mAccessibilityJSONObject;
     63 
     64     // Whether the accessibility script has been injected into the current page.
     65     private boolean mAccessibilityScriptInjected;
     66 
     67     // Constants for determining script injection strategy.
     68     private static final int ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED = -1;
     69     private static final int ACCESSIBILITY_SCRIPT_INJECTION_OPTED_OUT = 0;
     70     @SuppressWarnings("unused")
     71     private static final int ACCESSIBILITY_SCRIPT_INJECTION_PROVIDED = 1;
     72 
     73     // Alias for TTS API exposed to JavaScript.
     74     private static final String ALIAS_TTS_JS_INTERFACE = "accessibility";
     75 
     76     // Alias for traversal callback exposed to JavaScript.
     77     private static final String ALIAS_TRAVERSAL_JS_INTERFACE = "accessibilityTraversal";
     78 
     79     // Template for JavaScript that injects a screen-reader.
     80     private static final String ACCESSIBILITY_SCREEN_READER_JAVASCRIPT_TEMPLATE =
     81             "javascript:(function() {" +
     82                     "    var chooser = document.createElement('script');" +
     83                     "    chooser.type = 'text/javascript';" +
     84                     "    chooser.src = '%1s';" +
     85                     "    document.getElementsByTagName('head')[0].appendChild(chooser);" +
     86                     "  })();";
     87 
     88     // Template for JavaScript that performs AndroidVox actions.
     89     private static final String ACCESSIBILITY_ANDROIDVOX_TEMPLATE =
     90             "cvox.AndroidVox.performAction('%1s')";
     91 
     92     /**
     93      * Creates an instance of the AccessibilityInjector based on
     94      * {@code webViewClassic}.
     95      *
     96      * @param webViewClassic The WebViewClassic that this AccessibilityInjector
     97      *            manages.
     98      */
     99     public AccessibilityInjector(WebViewClassic webViewClassic) {
    100         mWebViewClassic = webViewClassic;
    101         mWebView = webViewClassic.getWebView();
    102         mContext = webViewClassic.getContext();
    103         mAccessibilityManager = AccessibilityManager.getInstance(mContext);
    104     }
    105 
    106     /**
    107      * Attempts to load scripting interfaces for accessibility.
    108      * <p>
    109      * This should be called when the window is attached.
    110      * </p>
    111      */
    112     public void addAccessibilityApisIfNecessary() {
    113         if (!isAccessibilityEnabled() || !isJavaScriptEnabled()) {
    114             return;
    115         }
    116 
    117         addTtsApis();
    118         addCallbackApis();
    119     }
    120 
    121     /**
    122      * Attempts to unload scripting interfaces for accessibility.
    123      * <p>
    124      * This should be called when the window is detached.
    125      * </p>
    126      */
    127     public void removeAccessibilityApisIfNecessary() {
    128         removeTtsApis();
    129         removeCallbackApis();
    130     }
    131 
    132     /**
    133      * Initializes an {@link AccessibilityNodeInfo} with the actions and
    134      * movement granularity levels supported by this
    135      * {@link AccessibilityInjector}.
    136      * <p>
    137      * If an action identifier is added in this method, this
    138      * {@link AccessibilityInjector} should also return {@code true} from
    139      * {@link #supportsAccessibilityAction(int)}.
    140      * </p>
    141      *
    142      * @param info The info to initialize.
    143      * @see View#onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo)
    144      */
    145     public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
    146         info.setMovementGranularities(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER
    147                 | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD
    148                 | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE
    149                 | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH
    150                 | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PAGE);
    151         info.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY);
    152         info.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY);
    153         info.addAction(AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT);
    154         info.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT);
    155         info.addAction(AccessibilityNodeInfo.ACTION_CLICK);
    156         info.setClickable(true);
    157     }
    158 
    159     /**
    160      * Returns {@code true} if this {@link AccessibilityInjector} should handle
    161      * the specified action.
    162      *
    163      * @param action An accessibility action identifier.
    164      * @return {@code true} if this {@link AccessibilityInjector} should handle
    165      *         the specified action.
    166      */
    167     public boolean supportsAccessibilityAction(int action) {
    168         switch (action) {
    169             case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY:
    170             case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY:
    171             case AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT:
    172             case AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT:
    173             case AccessibilityNodeInfo.ACTION_CLICK:
    174                 return true;
    175             default:
    176                 return false;
    177         }
    178     }
    179 
    180     /**
    181      * Performs the specified accessibility action.
    182      *
    183      * @param action The identifier of the action to perform.
    184      * @param arguments The action arguments, or {@code null} if no arguments.
    185      * @return {@code true} if the action was successful.
    186      * @see View#performAccessibilityAction(int, Bundle)
    187      */
    188     public boolean performAccessibilityAction(int action, Bundle arguments) {
    189         if (!isAccessibilityEnabled()) {
    190             mAccessibilityScriptInjected = false;
    191             toggleFallbackAccessibilityInjector(false);
    192             return false;
    193         }
    194 
    195         if (mAccessibilityScriptInjected) {
    196             return sendActionToAndroidVox(action, arguments);
    197         }
    198 
    199         if (mAccessibilityInjectorFallback != null) {
    200             return mAccessibilityInjectorFallback.performAccessibilityAction(action, arguments);
    201         }
    202 
    203         return false;
    204     }
    205 
    206     /**
    207      * Attempts to handle key events when accessibility is turned on.
    208      *
    209      * @param event The key event to handle.
    210      * @return {@code true} if the event was handled.
    211      */
    212     public boolean handleKeyEventIfNecessary(KeyEvent event) {
    213         if (!isAccessibilityEnabled()) {
    214             mAccessibilityScriptInjected = false;
    215             toggleFallbackAccessibilityInjector(false);
    216             return false;
    217         }
    218 
    219         if (mAccessibilityScriptInjected) {
    220             // if an accessibility script is injected we delegate to it the key
    221             // handling. this script is a screen reader which is a fully fledged
    222             // solution for blind users to navigate in and interact with web
    223             // pages.
    224             if (event.getAction() == KeyEvent.ACTION_UP) {
    225                 mWebViewClassic.sendBatchableInputMessage(EventHub.KEY_UP, 0, 0, event);
    226             } else if (event.getAction() == KeyEvent.ACTION_DOWN) {
    227                 mWebViewClassic.sendBatchableInputMessage(EventHub.KEY_DOWN, 0, 0, event);
    228             } else {
    229                 return false;
    230             }
    231 
    232             return true;
    233         }
    234 
    235         if (mAccessibilityInjectorFallback != null) {
    236             // if an accessibility injector is present (no JavaScript enabled or
    237             // the site opts out injecting our JavaScript screen reader) we let
    238             // it decide whether to act on and consume the event.
    239             return mAccessibilityInjectorFallback.onKeyEvent(event);
    240         }
    241 
    242         return false;
    243     }
    244 
    245     /**
    246      * Attempts to handle selection change events when accessibility is using a
    247      * non-JavaScript method.
    248      *
    249      * @param selectionString The selection string.
    250      */
    251     public void handleSelectionChangedIfNecessary(String selectionString) {
    252         if (mAccessibilityInjectorFallback != null) {
    253             mAccessibilityInjectorFallback.onSelectionStringChange(selectionString);
    254         }
    255     }
    256 
    257     /**
    258      * Prepares for injecting accessibility scripts into a new page.
    259      *
    260      * @param url The URL that will be loaded.
    261      */
    262     public void onPageStarted(String url) {
    263         mAccessibilityScriptInjected = false;
    264     }
    265 
    266     /**
    267      * Attempts to inject the accessibility script using a {@code <script>} tag.
    268      * <p>
    269      * This should be called after a page has finished loading.
    270      * </p>
    271      *
    272      * @param url The URL that just finished loading.
    273      */
    274     public void onPageFinished(String url) {
    275         if (!isAccessibilityEnabled()) {
    276             mAccessibilityScriptInjected = false;
    277             toggleFallbackAccessibilityInjector(false);
    278             return;
    279         }
    280 
    281         if (!shouldInjectJavaScript(url)) {
    282             toggleFallbackAccessibilityInjector(true);
    283             return;
    284         }
    285 
    286         toggleFallbackAccessibilityInjector(false);
    287 
    288         final String injectionUrl = getScreenReaderInjectionUrl();
    289         mWebView.loadUrl(injectionUrl);
    290 
    291         mAccessibilityScriptInjected = true;
    292     }
    293 
    294     /**
    295      * Toggles the non-JavaScript method for handling accessibility.
    296      *
    297      * @param enabled {@code true} to enable the non-JavaScript method, or
    298      *            {@code false} to disable it.
    299      */
    300     private void toggleFallbackAccessibilityInjector(boolean enabled) {
    301         if (enabled && (mAccessibilityInjectorFallback == null)) {
    302             mAccessibilityInjectorFallback = new AccessibilityInjectorFallback(mWebViewClassic);
    303         } else {
    304             mAccessibilityInjectorFallback = null;
    305         }
    306     }
    307 
    308     /**
    309      * Determines whether it's okay to inject JavaScript into a given URL.
    310      *
    311      * @param url The URL to check.
    312      * @return {@code true} if JavaScript should be injected, {@code false} if a
    313      *         non-JavaScript method should be used.
    314      */
    315     private boolean shouldInjectJavaScript(String url) {
    316         // Respect the WebView's JavaScript setting.
    317         if (!isJavaScriptEnabled()) {
    318             return false;
    319         }
    320 
    321         // Allow the page to opt out of Accessibility script injection.
    322         if (getAxsUrlParameterValue(url) == ACCESSIBILITY_SCRIPT_INJECTION_OPTED_OUT) {
    323             return false;
    324         }
    325 
    326         // The user must explicitly enable Accessibility script injection.
    327         if (!isScriptInjectionEnabled()) {
    328             return false;
    329         }
    330 
    331         return true;
    332     }
    333 
    334     /**
    335      * @return {@code true} if the user has explicitly enabled Accessibility
    336      *         script injection.
    337      */
    338     private boolean isScriptInjectionEnabled() {
    339         final int injectionSetting = Settings.Secure.getInt(
    340                 mContext.getContentResolver(), Settings.Secure.ACCESSIBILITY_SCRIPT_INJECTION, 0);
    341         return (injectionSetting == 1);
    342     }
    343 
    344     /**
    345      * Attempts to initialize and add interfaces for TTS, if that hasn't already
    346      * been done.
    347      */
    348     private void addTtsApis() {
    349         if (mTextToSpeech != null) {
    350             return;
    351         }
    352 
    353         final String pkgName = mContext.getPackageName();
    354 
    355         mTextToSpeech = new TextToSpeech(mContext, null, null, pkgName + ".**webview**", true);
    356         mWebView.addJavascriptInterface(mTextToSpeech, ALIAS_TTS_JS_INTERFACE);
    357     }
    358 
    359     /**
    360      * Attempts to shutdown and remove interfaces for TTS, if that hasn't
    361      * already been done.
    362      */
    363     private void removeTtsApis() {
    364         if (mTextToSpeech == null) {
    365             return;
    366         }
    367 
    368         mWebView.removeJavascriptInterface(ALIAS_TTS_JS_INTERFACE);
    369         mTextToSpeech.stop();
    370         mTextToSpeech.shutdown();
    371         mTextToSpeech = null;
    372     }
    373 
    374     private void addCallbackApis() {
    375         if (mCallback != null) {
    376             return;
    377         }
    378 
    379         mCallback = new CallbackHandler(ALIAS_TRAVERSAL_JS_INTERFACE);
    380         mWebView.addJavascriptInterface(mCallback, ALIAS_TRAVERSAL_JS_INTERFACE);
    381     }
    382 
    383     private void removeCallbackApis() {
    384         if (mCallback == null) {
    385             return;
    386         }
    387 
    388         mWebView.removeJavascriptInterface(ALIAS_TRAVERSAL_JS_INTERFACE);
    389         mCallback = null;
    390     }
    391 
    392     /**
    393      * Returns the script injection preference requested by the URL, or
    394      * {@link #ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED} if the page has no
    395      * preference.
    396      *
    397      * @param url The URL to check.
    398      * @return A script injection preference.
    399      */
    400     private int getAxsUrlParameterValue(String url) {
    401         if (url == null) {
    402             return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED;
    403         }
    404 
    405         try {
    406             final List<NameValuePair> params = URLEncodedUtils.parse(new URI(url), null);
    407 
    408             for (NameValuePair param : params) {
    409                 if ("axs".equals(param.getName())) {
    410                     return verifyInjectionValue(param.getValue());
    411                 }
    412             }
    413         } catch (URISyntaxException e) {
    414             // Do nothing.
    415         }
    416 
    417         return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED;
    418     }
    419 
    420     private int verifyInjectionValue(String value) {
    421         try {
    422             final int parsed = Integer.parseInt(value);
    423 
    424             switch (parsed) {
    425                 case ACCESSIBILITY_SCRIPT_INJECTION_OPTED_OUT:
    426                     return ACCESSIBILITY_SCRIPT_INJECTION_OPTED_OUT;
    427                 case ACCESSIBILITY_SCRIPT_INJECTION_PROVIDED:
    428                     return ACCESSIBILITY_SCRIPT_INJECTION_PROVIDED;
    429             }
    430         } catch (NumberFormatException e) {
    431             // Do nothing.
    432         }
    433 
    434         return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED;
    435     }
    436 
    437     /**
    438      * @return The URL for injecting the screen reader.
    439      */
    440     private String getScreenReaderInjectionUrl() {
    441         final String screenReaderUrl = Settings.Secure.getString(
    442                 mContext.getContentResolver(), Settings.Secure.ACCESSIBILITY_SCREEN_READER_URL);
    443         return String.format(ACCESSIBILITY_SCREEN_READER_JAVASCRIPT_TEMPLATE, screenReaderUrl);
    444     }
    445 
    446     /**
    447      * @return {@code true} if JavaScript is enabled in the {@link WebView}
    448      *         settings.
    449      */
    450     private boolean isJavaScriptEnabled() {
    451         return mWebView.getSettings().getJavaScriptEnabled();
    452     }
    453 
    454     /**
    455      * @return {@code true} if accessibility is enabled.
    456      */
    457     private boolean isAccessibilityEnabled() {
    458         return mAccessibilityManager.isEnabled();
    459     }
    460 
    461     /**
    462      * Packs an accessibility action into a JSON object and sends it to AndroidVox.
    463      *
    464      * @param action The action identifier.
    465      * @param arguments The action arguments, if applicable.
    466      * @return The result of the action.
    467      */
    468     private boolean sendActionToAndroidVox(int action, Bundle arguments) {
    469         if (mAccessibilityJSONObject == null) {
    470             mAccessibilityJSONObject = new JSONObject();
    471         } else {
    472             // Remove all keys from the object.
    473             final Iterator<?> keys = mAccessibilityJSONObject.keys();
    474             while (keys.hasNext()) {
    475                 keys.next();
    476                 keys.remove();
    477             }
    478         }
    479 
    480         try {
    481             mAccessibilityJSONObject.accumulate("action", action);
    482 
    483             switch (action) {
    484                 case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY:
    485                 case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY:
    486                     if (arguments != null) {
    487                         final int granularity = arguments.getInt(
    488                                 AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT);
    489                         mAccessibilityJSONObject.accumulate("granularity", granularity);
    490                     }
    491                     break;
    492                 case AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT:
    493                 case AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT:
    494                     if (arguments != null) {
    495                         final String element = arguments.getString(
    496                                 AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING);
    497                         mAccessibilityJSONObject.accumulate("element", element);
    498                     }
    499                     break;
    500             }
    501         } catch (JSONException e) {
    502             return false;
    503         }
    504 
    505         final String jsonString = mAccessibilityJSONObject.toString();
    506         final String jsCode = String.format(ACCESSIBILITY_ANDROIDVOX_TEMPLATE, jsonString);
    507         return mCallback.performAction(mWebView, jsCode);
    508     }
    509 
    510     /**
    511      * Exposes result interface to JavaScript.
    512      */
    513     private static class CallbackHandler {
    514         private static final String JAVASCRIPT_ACTION_TEMPLATE =
    515                 "javascript:(function() { %s.onResult(%d, %s); })();";
    516 
    517         // Time in milliseconds to wait for a result before failing.
    518         private static final long RESULT_TIMEOUT = 5000;
    519 
    520         private final AtomicInteger mResultIdCounter = new AtomicInteger();
    521         private final Object mResultLock = new Object();
    522         private final String mInterfaceName;
    523 
    524         private boolean mResult = false;
    525         private long mResultId = -1;
    526 
    527         private CallbackHandler(String interfaceName) {
    528             mInterfaceName = interfaceName;
    529         }
    530 
    531         /**
    532          * Performs an action and attempts to wait for a result.
    533          *
    534          * @param webView The WebView to perform the action on.
    535          * @param code JavaScript code that evaluates to a result.
    536          * @return The result of the action, or false if it timed out.
    537          */
    538         private boolean performAction(WebView webView, String code) {
    539             final int resultId = mResultIdCounter.getAndIncrement();
    540             final String url = String.format(
    541                     JAVASCRIPT_ACTION_TEMPLATE, mInterfaceName, resultId, code);
    542             webView.loadUrl(url);
    543 
    544             return getResultAndClear(resultId);
    545         }
    546 
    547         /**
    548          * Gets the result of a request to perform an accessibility action.
    549          *
    550          * @param resultId The result id to match the result with the request.
    551          * @return The result of the request.
    552          */
    553         private boolean getResultAndClear(int resultId) {
    554             synchronized (mResultLock) {
    555                 final boolean success = waitForResultTimedLocked(resultId);
    556                 final boolean result = success ? mResult : false;
    557                 clearResultLocked();
    558                 return result;
    559             }
    560         }
    561 
    562         /**
    563          * Clears the result state.
    564          */
    565         private void clearResultLocked() {
    566             mResultId = -1;
    567             mResult = false;
    568         }
    569 
    570         /**
    571          * Waits up to a given bound for a result of a request and returns it.
    572          *
    573          * @param resultId The result id to match the result with the request.
    574          * @return Whether the result was received.
    575          */
    576         private boolean waitForResultTimedLocked(int resultId) {
    577             long waitTimeMillis = RESULT_TIMEOUT;
    578             final long startTimeMillis = SystemClock.uptimeMillis();
    579             while (true) {
    580                 try {
    581                     if (mResultId == resultId) {
    582                         return true;
    583                     }
    584                     if (mResultId > resultId) {
    585                         return false;
    586                     }
    587                     final long elapsedTimeMillis = SystemClock.uptimeMillis() - startTimeMillis;
    588                     waitTimeMillis = RESULT_TIMEOUT - elapsedTimeMillis;
    589                     if (waitTimeMillis <= 0) {
    590                         return false;
    591                     }
    592                     mResultLock.wait(waitTimeMillis);
    593                 } catch (InterruptedException ie) {
    594                     /* ignore */
    595                 }
    596             }
    597         }
    598 
    599         /**
    600          * Callback exposed to JavaScript. Handles returning the result of a
    601          * request to a waiting (or potentially timed out) thread.
    602          *
    603          * @param id The result id of the request as a {@link String}.
    604          * @param result The result of the request as a {@link String}.
    605          */
    606         @SuppressWarnings("unused")
    607         public void onResult(String id, String result) {
    608             final long resultId;
    609 
    610             try {
    611                 resultId = Long.parseLong(id);
    612             } catch (NumberFormatException e) {
    613                 return;
    614             }
    615 
    616             synchronized (mResultLock) {
    617                 if (resultId > mResultId) {
    618                     mResult = Boolean.parseBoolean(result);
    619                     mResultId = resultId;
    620                 }
    621                 mResultLock.notifyAll();
    622             }
    623         }
    624     }
    625 }
    626