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.Handler;
     22 import android.os.SystemClock;
     23 import android.provider.Settings;
     24 import android.speech.tts.TextToSpeech;
     25 import android.speech.tts.TextToSpeech.Engine;
     26 import android.speech.tts.TextToSpeech.OnInitListener;
     27 import android.speech.tts.UtteranceProgressListener;
     28 import android.util.Log;
     29 import android.view.KeyEvent;
     30 import android.view.View;
     31 import android.view.accessibility.AccessibilityManager;
     32 import android.view.accessibility.AccessibilityNodeInfo;
     33 import android.webkit.WebViewCore.EventHub;
     34 
     35 import org.apache.http.NameValuePair;
     36 import org.apache.http.client.utils.URLEncodedUtils;
     37 import org.json.JSONException;
     38 import org.json.JSONObject;
     39 
     40 import java.net.URI;
     41 import java.net.URISyntaxException;
     42 import java.util.HashMap;
     43 import java.util.Iterator;
     44 import java.util.List;
     45 import java.util.concurrent.atomic.AtomicInteger;
     46 
     47 /**
     48  * Handles injecting accessibility JavaScript and related JavaScript -> Java
     49  * APIs.
     50  */
     51 class AccessibilityInjector {
     52     private static final String TAG = AccessibilityInjector.class.getSimpleName();
     53 
     54     private static boolean DEBUG = false;
     55 
     56     // The WebViewClassic this injector is responsible for managing.
     57     private final WebViewClassic mWebViewClassic;
     58 
     59     // Cached reference to mWebViewClassic.getContext(), for convenience.
     60     private final Context mContext;
     61 
     62     // Cached reference to mWebViewClassic.getWebView(), for convenience.
     63     private final WebView mWebView;
     64 
     65     // The Java objects that are exposed to JavaScript.
     66     private TextToSpeechWrapper mTextToSpeech;
     67     private CallbackHandler mCallback;
     68 
     69     // Lazily loaded helper objects.
     70     private AccessibilityManager mAccessibilityManager;
     71     private AccessibilityInjectorFallback mAccessibilityInjectorFallback;
     72     private JSONObject mAccessibilityJSONObject;
     73 
     74     // Whether the accessibility script has been injected into the current page.
     75     private boolean mAccessibilityScriptInjected;
     76 
     77     // Constants for determining script injection strategy.
     78     private static final int ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED = -1;
     79     private static final int ACCESSIBILITY_SCRIPT_INJECTION_OPTED_OUT = 0;
     80     @SuppressWarnings("unused")
     81     private static final int ACCESSIBILITY_SCRIPT_INJECTION_PROVIDED = 1;
     82 
     83     // Alias for TTS API exposed to JavaScript.
     84     private static final String ALIAS_TTS_JS_INTERFACE = "accessibility";
     85 
     86     // Alias for traversal callback exposed to JavaScript.
     87     private static final String ALIAS_TRAVERSAL_JS_INTERFACE = "accessibilityTraversal";
     88 
     89     // Template for JavaScript that injects a screen-reader.
     90     private static final String ACCESSIBILITY_SCREEN_READER_JAVASCRIPT_TEMPLATE =
     91             "javascript:(function() {" +
     92                     "    var chooser = document.createElement('script');" +
     93                     "    chooser.type = 'text/javascript';" +
     94                     "    chooser.src = '%1s';" +
     95                     "    document.getElementsByTagName('head')[0].appendChild(chooser);" +
     96                     "  })();";
     97 
     98     // Template for JavaScript that performs AndroidVox actions.
     99     private static final String ACCESSIBILITY_ANDROIDVOX_TEMPLATE =
    100             "(function() {" +
    101                     "  if ((typeof(cvox) != 'undefined')" +
    102                     "      && (cvox != null)" +
    103                     "      && (typeof(cvox.ChromeVox) != 'undefined')" +
    104                     "      && (cvox.ChromeVox != null)" +
    105                     "      && (typeof(cvox.AndroidVox) != 'undefined')" +
    106                     "      && (cvox.AndroidVox != null)" +
    107                     "      && cvox.ChromeVox.isActive) {" +
    108                     "    return cvox.AndroidVox.performAction('%1s');" +
    109                     "  } else {" +
    110                     "    return false;" +
    111                     "  }" +
    112                     "})()";
    113 
    114     // JS code used to shut down an active AndroidVox instance.
    115     private static final String TOGGLE_CVOX_TEMPLATE =
    116             "javascript:(function() {" +
    117                     "  if ((typeof(cvox) != 'undefined')" +
    118                     "      && (cvox != null)" +
    119                     "      && (typeof(cvox.ChromeVox) != 'undefined')" +
    120                     "      && (cvox.ChromeVox != null)" +
    121                     "      && (typeof(cvox.ChromeVox.host) != 'undefined')" +
    122                     "      && (cvox.ChromeVox.host != null)) {" +
    123                     "    cvox.ChromeVox.host.activateOrDeactivateChromeVox(%b);" +
    124                     "  }" +
    125                     "})();";
    126 
    127     /**
    128      * Creates an instance of the AccessibilityInjector based on
    129      * {@code webViewClassic}.
    130      *
    131      * @param webViewClassic The WebViewClassic that this AccessibilityInjector
    132      *            manages.
    133      */
    134     public AccessibilityInjector(WebViewClassic webViewClassic) {
    135         mWebViewClassic = webViewClassic;
    136         mWebView = webViewClassic.getWebView();
    137         mContext = webViewClassic.getContext();
    138         mAccessibilityManager = AccessibilityManager.getInstance(mContext);
    139     }
    140 
    141     /**
    142      * If JavaScript is enabled, pauses or resumes AndroidVox.
    143      *
    144      * @param enabled Whether feedback should be enabled.
    145      */
    146     public void toggleAccessibilityFeedback(boolean enabled) {
    147         if (!isAccessibilityEnabled() || !isJavaScriptEnabled()) {
    148             return;
    149         }
    150 
    151         toggleAndroidVox(enabled);
    152 
    153         if (!enabled && (mTextToSpeech != null)) {
    154             mTextToSpeech.stop();
    155         }
    156     }
    157 
    158     /**
    159      * Attempts to load scripting interfaces for accessibility.
    160      * <p>
    161      * This should only be called before a page loads.
    162      */
    163     public void addAccessibilityApisIfNecessary() {
    164         if (!isAccessibilityEnabled() || !isJavaScriptEnabled()) {
    165             return;
    166         }
    167 
    168         addTtsApis();
    169         addCallbackApis();
    170     }
    171 
    172     /**
    173      * Attempts to unload scripting interfaces for accessibility.
    174      * <p>
    175      * This should only be called before a page loads.
    176      */
    177     private void removeAccessibilityApisIfNecessary() {
    178         removeTtsApis();
    179         removeCallbackApis();
    180     }
    181 
    182     /**
    183      * Destroys this accessibility injector.
    184      */
    185     public void destroy() {
    186         if (mTextToSpeech != null) {
    187             mTextToSpeech.shutdown();
    188             mTextToSpeech = null;
    189         }
    190 
    191         if (mCallback != null) {
    192             mCallback = null;
    193         }
    194     }
    195 
    196     private void toggleAndroidVox(boolean state) {
    197         if (!mAccessibilityScriptInjected) {
    198             return;
    199         }
    200 
    201         final String code = String.format(TOGGLE_CVOX_TEMPLATE, state);
    202         mWebView.loadUrl(code);
    203     }
    204 
    205     /**
    206      * Initializes an {@link AccessibilityNodeInfo} with the actions and
    207      * movement granularity levels supported by this
    208      * {@link AccessibilityInjector}.
    209      * <p>
    210      * If an action identifier is added in this method, this
    211      * {@link AccessibilityInjector} should also return {@code true} from
    212      * {@link #supportsAccessibilityAction(int)}.
    213      * </p>
    214      *
    215      * @param info The info to initialize.
    216      * @see View#onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo)
    217      */
    218     public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
    219         info.setMovementGranularities(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER
    220                 | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD
    221                 | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE
    222                 | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH
    223                 | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PAGE);
    224         info.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY);
    225         info.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY);
    226         info.addAction(AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT);
    227         info.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT);
    228         info.addAction(AccessibilityNodeInfo.ACTION_CLICK);
    229         info.setClickable(true);
    230     }
    231 
    232     /**
    233      * Returns {@code true} if this {@link AccessibilityInjector} should handle
    234      * the specified action.
    235      *
    236      * @param action An accessibility action identifier.
    237      * @return {@code true} if this {@link AccessibilityInjector} should handle
    238      *         the specified action.
    239      */
    240     public boolean supportsAccessibilityAction(int action) {
    241         switch (action) {
    242             case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY:
    243             case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY:
    244             case AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT:
    245             case AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT:
    246             case AccessibilityNodeInfo.ACTION_CLICK:
    247                 return true;
    248             default:
    249                 return false;
    250         }
    251     }
    252 
    253     /**
    254      * Performs the specified accessibility action.
    255      *
    256      * @param action The identifier of the action to perform.
    257      * @param arguments The action arguments, or {@code null} if no arguments.
    258      * @return {@code true} if the action was successful.
    259      * @see View#performAccessibilityAction(int, Bundle)
    260      */
    261     public boolean performAccessibilityAction(int action, Bundle arguments) {
    262         if (!isAccessibilityEnabled()) {
    263             mAccessibilityScriptInjected = false;
    264             toggleFallbackAccessibilityInjector(false);
    265             return false;
    266         }
    267 
    268         if (mAccessibilityScriptInjected) {
    269             return sendActionToAndroidVox(action, arguments);
    270         }
    271 
    272         if (mAccessibilityInjectorFallback != null) {
    273             return mAccessibilityInjectorFallback.performAccessibilityAction(action, arguments);
    274         }
    275 
    276         return false;
    277     }
    278 
    279     /**
    280      * Attempts to handle key events when accessibility is turned on.
    281      *
    282      * @param event The key event to handle.
    283      * @return {@code true} if the event was handled.
    284      */
    285     public boolean handleKeyEventIfNecessary(KeyEvent event) {
    286         if (!isAccessibilityEnabled()) {
    287             mAccessibilityScriptInjected = false;
    288             toggleFallbackAccessibilityInjector(false);
    289             return false;
    290         }
    291 
    292         if (mAccessibilityScriptInjected) {
    293             // if an accessibility script is injected we delegate to it the key
    294             // handling. this script is a screen reader which is a fully fledged
    295             // solution for blind users to navigate in and interact with web
    296             // pages.
    297             if (event.getAction() == KeyEvent.ACTION_UP) {
    298                 mWebViewClassic.sendBatchableInputMessage(EventHub.KEY_UP, 0, 0, event);
    299             } else if (event.getAction() == KeyEvent.ACTION_DOWN) {
    300                 mWebViewClassic.sendBatchableInputMessage(EventHub.KEY_DOWN, 0, 0, event);
    301             } else {
    302                 return false;
    303             }
    304 
    305             return true;
    306         }
    307 
    308         if (mAccessibilityInjectorFallback != null) {
    309             // if an accessibility injector is present (no JavaScript enabled or
    310             // the site opts out injecting our JavaScript screen reader) we let
    311             // it decide whether to act on and consume the event.
    312             return mAccessibilityInjectorFallback.onKeyEvent(event);
    313         }
    314 
    315         return false;
    316     }
    317 
    318     /**
    319      * Attempts to handle selection change events when accessibility is using a
    320      * non-JavaScript method.
    321      * <p>
    322      * This must not be called from the main thread.
    323      *
    324      * @param selection The selection string.
    325      * @param token The selection request token.
    326      */
    327     public void onSelectionStringChangedWebCoreThread(String selection, int token) {
    328         if (mAccessibilityInjectorFallback != null) {
    329             mAccessibilityInjectorFallback.onSelectionStringChangedWebCoreThread(selection, token);
    330         }
    331     }
    332 
    333     /**
    334      * Prepares for injecting accessibility scripts into a new page.
    335      *
    336      * @param url The URL that will be loaded.
    337      */
    338     public void onPageStarted(String url) {
    339         mAccessibilityScriptInjected = false;
    340         if (DEBUG) {
    341             Log.w(TAG, "[" + mWebView.hashCode() + "] Started loading new page");
    342         }
    343         addAccessibilityApisIfNecessary();
    344     }
    345 
    346     /**
    347      * Attempts to inject the accessibility script using a {@code <script>} tag.
    348      * <p>
    349      * This should be called after a page has finished loading.
    350      * </p>
    351      *
    352      * @param url The URL that just finished loading.
    353      */
    354     public void onPageFinished(String url) {
    355         if (!isAccessibilityEnabled()) {
    356             toggleFallbackAccessibilityInjector(false);
    357             return;
    358         }
    359 
    360         toggleFallbackAccessibilityInjector(true);
    361 
    362         if (shouldInjectJavaScript(url)) {
    363             // If we're supposed to use the JS screen reader, request a
    364             // callback to confirm that CallbackHandler is working.
    365             if (DEBUG) {
    366                 Log.d(TAG, "[" + mWebView.hashCode() + "] Request callback ");
    367             }
    368 
    369             mCallback.requestCallback(mWebView, mInjectScriptRunnable);
    370         }
    371     }
    372 
    373     /**
    374      * Runnable used to inject the JavaScript-based screen reader if the
    375      * {@link CallbackHandler} API was successfully exposed to JavaScript.
    376      */
    377     private Runnable mInjectScriptRunnable = new Runnable() {
    378         @Override
    379         public void run() {
    380             if (DEBUG) {
    381                 Log.d(TAG, "[" + mWebView.hashCode() + "] Received callback");
    382             }
    383 
    384             injectJavaScript();
    385         }
    386     };
    387 
    388     /**
    389      * Called by {@link #mInjectScriptRunnable} to inject the JavaScript-based
    390      * screen reader after confirming that the {@link CallbackHandler} API is
    391      * functional.
    392      */
    393     private void injectJavaScript() {
    394         toggleFallbackAccessibilityInjector(false);
    395 
    396         if (!mAccessibilityScriptInjected) {
    397             mAccessibilityScriptInjected = true;
    398             final String injectionUrl = getScreenReaderInjectionUrl();
    399             mWebView.loadUrl(injectionUrl);
    400             if (DEBUG) {
    401                 Log.d(TAG, "[" + mWebView.hashCode() + "] Loading screen reader into WebView");
    402             }
    403         } else {
    404             if (DEBUG) {
    405                 Log.w(TAG, "[" + mWebView.hashCode() + "] Attempted to inject screen reader twice");
    406             }
    407         }
    408     }
    409 
    410     /**
    411      * Adjusts the accessibility injection state to reflect changes in the
    412      * JavaScript enabled state.
    413      *
    414      * @param enabled Whether JavaScript is enabled.
    415      */
    416     public void updateJavaScriptEnabled(boolean enabled) {
    417         if (enabled) {
    418             addAccessibilityApisIfNecessary();
    419         } else {
    420             removeAccessibilityApisIfNecessary();
    421         }
    422 
    423         // We have to reload the page after adding or removing APIs.
    424         mWebView.reload();
    425     }
    426 
    427     /**
    428      * Toggles the non-JavaScript method for handling accessibility.
    429      *
    430      * @param enabled {@code true} to enable the non-JavaScript method, or
    431      *            {@code false} to disable it.
    432      */
    433     private void toggleFallbackAccessibilityInjector(boolean enabled) {
    434         if (enabled && (mAccessibilityInjectorFallback == null)) {
    435             mAccessibilityInjectorFallback = new AccessibilityInjectorFallback(mWebViewClassic);
    436         } else {
    437             mAccessibilityInjectorFallback = null;
    438         }
    439     }
    440 
    441     /**
    442      * Determines whether it's okay to inject JavaScript into a given URL.
    443      *
    444      * @param url The URL to check.
    445      * @return {@code true} if JavaScript should be injected, {@code false} if a
    446      *         non-JavaScript method should be used.
    447      */
    448     private boolean shouldInjectJavaScript(String url) {
    449         // Respect the WebView's JavaScript setting.
    450         if (!isJavaScriptEnabled()) {
    451             return false;
    452         }
    453 
    454         // Allow the page to opt out of Accessibility script injection.
    455         if (getAxsUrlParameterValue(url) == ACCESSIBILITY_SCRIPT_INJECTION_OPTED_OUT) {
    456             return false;
    457         }
    458 
    459         // The user must explicitly enable Accessibility script injection.
    460         if (!isScriptInjectionEnabled()) {
    461             return false;
    462         }
    463 
    464         return true;
    465     }
    466 
    467     /**
    468      * @return {@code true} if the user has explicitly enabled Accessibility
    469      *         script injection.
    470      */
    471     private boolean isScriptInjectionEnabled() {
    472         final int injectionSetting = Settings.Secure.getInt(
    473                 mContext.getContentResolver(), Settings.Secure.ACCESSIBILITY_SCRIPT_INJECTION, 0);
    474         return (injectionSetting == 1);
    475     }
    476 
    477     /**
    478      * Attempts to initialize and add interfaces for TTS, if that hasn't already
    479      * been done.
    480      */
    481     private void addTtsApis() {
    482         if (mTextToSpeech == null) {
    483             mTextToSpeech = new TextToSpeechWrapper(mContext);
    484         }
    485 
    486         mWebView.addJavascriptInterface(mTextToSpeech, ALIAS_TTS_JS_INTERFACE);
    487     }
    488 
    489     /**
    490      * Attempts to shutdown and remove interfaces for TTS, if that hasn't
    491      * already been done.
    492      */
    493     private void removeTtsApis() {
    494         if (mTextToSpeech != null) {
    495             mTextToSpeech.stop();
    496             mTextToSpeech.shutdown();
    497             mTextToSpeech = null;
    498         }
    499 
    500         mWebView.removeJavascriptInterface(ALIAS_TTS_JS_INTERFACE);
    501     }
    502 
    503     private void addCallbackApis() {
    504         if (mCallback == null) {
    505             mCallback = new CallbackHandler(ALIAS_TRAVERSAL_JS_INTERFACE);
    506         }
    507 
    508         mWebView.addJavascriptInterface(mCallback, ALIAS_TRAVERSAL_JS_INTERFACE);
    509     }
    510 
    511     private void removeCallbackApis() {
    512         if (mCallback != null) {
    513             mCallback = null;
    514         }
    515 
    516         mWebView.removeJavascriptInterface(ALIAS_TRAVERSAL_JS_INTERFACE);
    517     }
    518 
    519     /**
    520      * Returns the script injection preference requested by the URL, or
    521      * {@link #ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED} if the page has no
    522      * preference.
    523      *
    524      * @param url The URL to check.
    525      * @return A script injection preference.
    526      */
    527     private int getAxsUrlParameterValue(String url) {
    528         if (url == null) {
    529             return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED;
    530         }
    531 
    532         try {
    533             final List<NameValuePair> params = URLEncodedUtils.parse(new URI(url), null);
    534 
    535             for (NameValuePair param : params) {
    536                 if ("axs".equals(param.getName())) {
    537                     return verifyInjectionValue(param.getValue());
    538                 }
    539             }
    540         } catch (URISyntaxException e) {
    541             // Do nothing.
    542         } catch (IllegalArgumentException e) {
    543             // Catch badly-formed URLs.
    544         }
    545 
    546         return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED;
    547     }
    548 
    549     private int verifyInjectionValue(String value) {
    550         try {
    551             final int parsed = Integer.parseInt(value);
    552 
    553             switch (parsed) {
    554                 case ACCESSIBILITY_SCRIPT_INJECTION_OPTED_OUT:
    555                     return ACCESSIBILITY_SCRIPT_INJECTION_OPTED_OUT;
    556                 case ACCESSIBILITY_SCRIPT_INJECTION_PROVIDED:
    557                     return ACCESSIBILITY_SCRIPT_INJECTION_PROVIDED;
    558             }
    559         } catch (NumberFormatException e) {
    560             // Do nothing.
    561         }
    562 
    563         return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED;
    564     }
    565 
    566     /**
    567      * @return The URL for injecting the screen reader.
    568      */
    569     private String getScreenReaderInjectionUrl() {
    570         final String screenReaderUrl = Settings.Secure.getString(
    571                 mContext.getContentResolver(), Settings.Secure.ACCESSIBILITY_SCREEN_READER_URL);
    572         return String.format(ACCESSIBILITY_SCREEN_READER_JAVASCRIPT_TEMPLATE, screenReaderUrl);
    573     }
    574 
    575     /**
    576      * @return {@code true} if JavaScript is enabled in the {@link WebView}
    577      *         settings.
    578      */
    579     private boolean isJavaScriptEnabled() {
    580         final WebSettings settings = mWebView.getSettings();
    581         if (settings == null) {
    582             return false;
    583         }
    584 
    585         return settings.getJavaScriptEnabled();
    586     }
    587 
    588     /**
    589      * @return {@code true} if accessibility is enabled.
    590      */
    591     private boolean isAccessibilityEnabled() {
    592         return mAccessibilityManager.isEnabled();
    593     }
    594 
    595     /**
    596      * Packs an accessibility action into a JSON object and sends it to AndroidVox.
    597      *
    598      * @param action The action identifier.
    599      * @param arguments The action arguments, if applicable.
    600      * @return The result of the action.
    601      */
    602     private boolean sendActionToAndroidVox(int action, Bundle arguments) {
    603         if (mAccessibilityJSONObject == null) {
    604             mAccessibilityJSONObject = new JSONObject();
    605         } else {
    606             // Remove all keys from the object.
    607             final Iterator<?> keys = mAccessibilityJSONObject.keys();
    608             while (keys.hasNext()) {
    609                 keys.next();
    610                 keys.remove();
    611             }
    612         }
    613 
    614         try {
    615             mAccessibilityJSONObject.accumulate("action", action);
    616 
    617             switch (action) {
    618                 case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY:
    619                 case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY:
    620                     if (arguments != null) {
    621                         final int granularity = arguments.getInt(
    622                                 AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT);
    623                         mAccessibilityJSONObject.accumulate("granularity", granularity);
    624                     }
    625                     break;
    626                 case AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT:
    627                 case AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT:
    628                     if (arguments != null) {
    629                         final String element = arguments.getString(
    630                                 AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING);
    631                         mAccessibilityJSONObject.accumulate("element", element);
    632                     }
    633                     break;
    634             }
    635         } catch (JSONException e) {
    636             return false;
    637         }
    638 
    639         final String jsonString = mAccessibilityJSONObject.toString();
    640         final String jsCode = String.format(ACCESSIBILITY_ANDROIDVOX_TEMPLATE, jsonString);
    641         return mCallback.performAction(mWebView, jsCode);
    642     }
    643 
    644     /**
    645      * Used to protect the TextToSpeech class, only exposing the methods we want to expose.
    646      */
    647     private static class TextToSpeechWrapper {
    648         private static final String WRAP_TAG = TextToSpeechWrapper.class.getSimpleName();
    649 
    650         /** Lock used to control access to the TextToSpeech object. */
    651         private final Object mTtsLock = new Object();
    652 
    653         private final HashMap<String, String> mTtsParams;
    654         private final TextToSpeech mTextToSpeech;
    655 
    656         /**
    657          * Whether this wrapper is ready to speak. If this is {@code true} then
    658          * {@link #mShutdown} is guaranteed to be {@code false}.
    659          */
    660         private volatile boolean mReady;
    661 
    662         /**
    663          * Whether this wrapper was shut down. If this is {@code true} then
    664          * {@link #mReady} is guaranteed to be {@code false}.
    665          */
    666         private volatile boolean mShutdown;
    667 
    668         public TextToSpeechWrapper(Context context) {
    669             if (DEBUG) {
    670                 Log.d(WRAP_TAG, "[" + hashCode() + "] Initializing text-to-speech on thread "
    671                         + Thread.currentThread().getId() + "...");
    672             }
    673 
    674             final String pkgName = context.getPackageName();
    675 
    676             mReady = false;
    677             mShutdown = false;
    678 
    679             mTtsParams = new HashMap<String, String>();
    680             mTtsParams.put(Engine.KEY_PARAM_UTTERANCE_ID, WRAP_TAG);
    681 
    682             mTextToSpeech = new TextToSpeech(
    683                     context, mInitListener, null, pkgName + ".**webview**", true);
    684             mTextToSpeech.setOnUtteranceProgressListener(mErrorListener);
    685         }
    686 
    687         @JavascriptInterface
    688         @SuppressWarnings("unused")
    689         public boolean isSpeaking() {
    690             synchronized (mTtsLock) {
    691                 if (!mReady) {
    692                     return false;
    693                 }
    694 
    695                 return mTextToSpeech.isSpeaking();
    696             }
    697         }
    698 
    699         @JavascriptInterface
    700         @SuppressWarnings("unused")
    701         public int speak(String text, int queueMode, HashMap<String, String> params) {
    702             synchronized (mTtsLock) {
    703                 if (!mReady) {
    704                     if (DEBUG) {
    705                         Log.w(WRAP_TAG, "[" + hashCode() + "] Attempted to speak before TTS init");
    706                     }
    707                     return TextToSpeech.ERROR;
    708                 } else {
    709                     if (DEBUG) {
    710                         Log.i(WRAP_TAG, "[" + hashCode() + "] Speak called from JS binder");
    711                     }
    712                 }
    713 
    714                 return mTextToSpeech.speak(text, queueMode, params);
    715             }
    716         }
    717 
    718         @JavascriptInterface
    719         @SuppressWarnings("unused")
    720         public int stop() {
    721             synchronized (mTtsLock) {
    722                 if (!mReady) {
    723                     if (DEBUG) {
    724                         Log.w(WRAP_TAG, "[" + hashCode() + "] Attempted to stop before initialize");
    725                     }
    726                     return TextToSpeech.ERROR;
    727                 } else {
    728                     if (DEBUG) {
    729                         Log.i(WRAP_TAG, "[" + hashCode() + "] Stop called from JS binder");
    730                     }
    731                 }
    732 
    733                 return mTextToSpeech.stop();
    734             }
    735         }
    736 
    737         @SuppressWarnings("unused")
    738         protected void shutdown() {
    739             synchronized (mTtsLock) {
    740                 if (!mReady) {
    741                     if (DEBUG) {
    742                         Log.w(WRAP_TAG, "[" + hashCode() + "] Called shutdown before initialize");
    743                     }
    744                 } else {
    745                     if (DEBUG) {
    746                         Log.i(WRAP_TAG, "[" + hashCode() + "] Shutting down text-to-speech from "
    747                                 + "thread " + Thread.currentThread().getId() + "...");
    748                     }
    749                 }
    750                 mShutdown = true;
    751                 mReady = false;
    752                 mTextToSpeech.shutdown();
    753             }
    754         }
    755 
    756         private final OnInitListener mInitListener = new OnInitListener() {
    757             @Override
    758             public void onInit(int status) {
    759                 synchronized (mTtsLock) {
    760                     if (!mShutdown && (status == TextToSpeech.SUCCESS)) {
    761                         if (DEBUG) {
    762                             Log.d(WRAP_TAG, "[" + TextToSpeechWrapper.this.hashCode()
    763                                     + "] Initialized successfully");
    764                         }
    765                         mReady = true;
    766                     } else {
    767                         if (DEBUG) {
    768                             Log.w(WRAP_TAG, "[" + TextToSpeechWrapper.this.hashCode()
    769                                     + "] Failed to initialize");
    770                         }
    771                         mReady = false;
    772                     }
    773                 }
    774             }
    775         };
    776 
    777         private final UtteranceProgressListener mErrorListener = new UtteranceProgressListener() {
    778             @Override
    779             public void onStart(String utteranceId) {
    780                 // Do nothing.
    781             }
    782 
    783             @Override
    784             public void onError(String utteranceId) {
    785                 if (DEBUG) {
    786                     Log.w(WRAP_TAG, "[" + TextToSpeechWrapper.this.hashCode()
    787                             + "] Failed to speak utterance");
    788                 }
    789             }
    790 
    791             @Override
    792             public void onDone(String utteranceId) {
    793                 // Do nothing.
    794             }
    795         };
    796     }
    797 
    798     /**
    799      * Exposes result interface to JavaScript.
    800      */
    801     private static class CallbackHandler {
    802         private static final String JAVASCRIPT_ACTION_TEMPLATE =
    803                 "javascript:(function() { %s.onResult(%d, %s); })();";
    804 
    805         // Time in milliseconds to wait for a result before failing.
    806         private static final long RESULT_TIMEOUT = 5000;
    807 
    808         private final AtomicInteger mResultIdCounter = new AtomicInteger();
    809         private final Object mResultLock = new Object();
    810         private final String mInterfaceName;
    811         private final Handler mMainHandler;
    812 
    813         private Runnable mCallbackRunnable;
    814 
    815         private boolean mResult = false;
    816         private int mResultId = -1;
    817 
    818         private CallbackHandler(String interfaceName) {
    819             mInterfaceName = interfaceName;
    820             mMainHandler = new Handler();
    821         }
    822 
    823         /**
    824          * Performs an action and attempts to wait for a result.
    825          *
    826          * @param webView The WebView to perform the action on.
    827          * @param code JavaScript code that evaluates to a result.
    828          * @return The result of the action, or false if it timed out.
    829          */
    830         private boolean performAction(WebView webView, String code) {
    831             final int resultId = mResultIdCounter.getAndIncrement();
    832             final String url = String.format(
    833                     JAVASCRIPT_ACTION_TEMPLATE, mInterfaceName, resultId, code);
    834             webView.loadUrl(url);
    835 
    836             return getResultAndClear(resultId);
    837         }
    838 
    839         /**
    840          * Gets the result of a request to perform an accessibility action.
    841          *
    842          * @param resultId The result id to match the result with the request.
    843          * @return The result of the request.
    844          */
    845         private boolean getResultAndClear(int resultId) {
    846             synchronized (mResultLock) {
    847                 final boolean success = waitForResultTimedLocked(resultId);
    848                 final boolean result = success ? mResult : false;
    849                 clearResultLocked();
    850                 return result;
    851             }
    852         }
    853 
    854         /**
    855          * Clears the result state.
    856          */
    857         private void clearResultLocked() {
    858             mResultId = -1;
    859             mResult = false;
    860         }
    861 
    862         /**
    863          * Waits up to a given bound for a result of a request and returns it.
    864          *
    865          * @param resultId The result id to match the result with the request.
    866          * @return Whether the result was received.
    867          */
    868         private boolean waitForResultTimedLocked(int resultId) {
    869             final long startTimeMillis = SystemClock.uptimeMillis();
    870 
    871             if (DEBUG) {
    872                 Log.d(TAG, "Waiting for CVOX result with ID " + resultId + "...");
    873             }
    874 
    875             while (true) {
    876                 // Fail if we received a callback from the future.
    877                 if (mResultId > resultId) {
    878                     if (DEBUG) {
    879                         Log.w(TAG, "Aborted CVOX result");
    880                     }
    881                     return false;
    882                 }
    883 
    884                 final long elapsedTimeMillis = (SystemClock.uptimeMillis() - startTimeMillis);
    885 
    886                 // Succeed if we received the callback we were expecting.
    887                 if (DEBUG) {
    888                     Log.w(TAG, "Check " + mResultId + " versus expected " + resultId);
    889                 }
    890                 if (mResultId == resultId) {
    891                     if (DEBUG) {
    892                         Log.w(TAG, "Received CVOX result after " + elapsedTimeMillis + " ms");
    893                     }
    894                     return true;
    895                 }
    896 
    897                 final long waitTimeMillis = (RESULT_TIMEOUT - elapsedTimeMillis);
    898 
    899                 // Fail if we've already exceeded the timeout.
    900                 if (waitTimeMillis <= 0) {
    901                     if (DEBUG) {
    902                         Log.w(TAG, "Timed out while waiting for CVOX result");
    903                     }
    904                     return false;
    905                 }
    906 
    907                 try {
    908                     if (DEBUG) {
    909                         Log.w(TAG, "Start waiting...");
    910                     }
    911                     mResultLock.wait(waitTimeMillis);
    912                 } catch (InterruptedException ie) {
    913                     if (DEBUG) {
    914                         Log.w(TAG, "Interrupted while waiting for CVOX result");
    915                     }
    916                 }
    917             }
    918         }
    919 
    920         /**
    921          * Callback exposed to JavaScript. Handles returning the result of a
    922          * request to a waiting (or potentially timed out) thread.
    923          *
    924          * @param id The result id of the request as a {@link String}.
    925          * @param result The result of the request as a {@link String}.
    926          */
    927         @JavascriptInterface
    928         @SuppressWarnings("unused")
    929         public void onResult(String id, String result) {
    930             if (DEBUG) {
    931                 Log.w(TAG, "Saw CVOX result of '" + result + "' for ID " + id);
    932             }
    933             final int resultId;
    934 
    935             try {
    936                 resultId = Integer.parseInt(id);
    937             } catch (NumberFormatException e) {
    938                 return;
    939             }
    940 
    941             synchronized (mResultLock) {
    942                 if (resultId > mResultId) {
    943                     mResult = Boolean.parseBoolean(result);
    944                     mResultId = resultId;
    945                 } else {
    946                     if (DEBUG) {
    947                         Log.w(TAG, "Result with ID " + resultId + " was stale vesus " + mResultId);
    948                     }
    949                 }
    950                 mResultLock.notifyAll();
    951             }
    952         }
    953 
    954         /**
    955          * Requests a callback to ensure that the JavaScript interface for this
    956          * object has been added successfully.
    957          *
    958          * @param webView The web view to request a callback from.
    959          * @param callbackRunnable Runnable to execute if a callback is received.
    960          */
    961         public void requestCallback(WebView webView, Runnable callbackRunnable) {
    962             mCallbackRunnable = callbackRunnable;
    963 
    964             webView.loadUrl("javascript:(function() { " + mInterfaceName + ".callback(); })();");
    965         }
    966 
    967         @JavascriptInterface
    968         @SuppressWarnings("unused")
    969         public void callback() {
    970             if (mCallbackRunnable != null) {
    971                 mMainHandler.post(mCallbackRunnable);
    972                 mCallbackRunnable = null;
    973             }
    974         }
    975     }
    976 }
    977