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
17package android.webkit;
18
19import android.content.Context;
20import android.os.Bundle;
21import android.os.Handler;
22import android.os.SystemClock;
23import android.provider.Settings;
24import android.speech.tts.TextToSpeech;
25import android.speech.tts.TextToSpeech.Engine;
26import android.speech.tts.TextToSpeech.OnInitListener;
27import android.speech.tts.UtteranceProgressListener;
28import android.util.Log;
29import android.view.KeyEvent;
30import android.view.View;
31import android.view.accessibility.AccessibilityManager;
32import android.view.accessibility.AccessibilityNodeInfo;
33import android.webkit.WebViewCore.EventHub;
34
35import org.apache.http.NameValuePair;
36import org.apache.http.client.utils.URLEncodedUtils;
37import org.json.JSONException;
38import org.json.JSONObject;
39
40import java.net.URI;
41import java.net.URISyntaxException;
42import java.util.HashMap;
43import java.util.Iterator;
44import java.util.List;
45import java.util.concurrent.atomic.AtomicInteger;
46
47/**
48 * Handles injecting accessibility JavaScript and related JavaScript -> Java
49 * APIs.
50 */
51class 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     *
322     * @param selectionString The selection string.
323     */
324    public void handleSelectionChangedIfNecessary(String selectionString) {
325        if (mAccessibilityInjectorFallback != null) {
326            mAccessibilityInjectorFallback.onSelectionStringChange(selectionString);
327        }
328    }
329
330    /**
331     * Prepares for injecting accessibility scripts into a new page.
332     *
333     * @param url The URL that will be loaded.
334     */
335    public void onPageStarted(String url) {
336        mAccessibilityScriptInjected = false;
337        if (DEBUG) {
338            Log.w(TAG, "[" + mWebView.hashCode() + "] Started loading new page");
339        }
340        addAccessibilityApisIfNecessary();
341    }
342
343    /**
344     * Attempts to inject the accessibility script using a {@code <script>} tag.
345     * <p>
346     * This should be called after a page has finished loading.
347     * </p>
348     *
349     * @param url The URL that just finished loading.
350     */
351    public void onPageFinished(String url) {
352        if (!isAccessibilityEnabled()) {
353            toggleFallbackAccessibilityInjector(false);
354            return;
355        }
356
357        toggleFallbackAccessibilityInjector(true);
358
359        if (shouldInjectJavaScript(url)) {
360            // If we're supposed to use the JS screen reader, request a
361            // callback to confirm that CallbackHandler is working.
362            if (DEBUG) {
363                Log.d(TAG, "[" + mWebView.hashCode() + "] Request callback ");
364            }
365
366            mCallback.requestCallback(mWebView, mInjectScriptRunnable);
367        }
368    }
369
370    /**
371     * Runnable used to inject the JavaScript-based screen reader if the
372     * {@link CallbackHandler} API was successfully exposed to JavaScript.
373     */
374    private Runnable mInjectScriptRunnable = new Runnable() {
375        @Override
376        public void run() {
377            if (DEBUG) {
378                Log.d(TAG, "[" + mWebView.hashCode() + "] Received callback");
379            }
380
381            injectJavaScript();
382        }
383    };
384
385    /**
386     * Called by {@link #mInjectScriptRunnable} to inject the JavaScript-based
387     * screen reader after confirming that the {@link CallbackHandler} API is
388     * functional.
389     */
390    private void injectJavaScript() {
391        toggleFallbackAccessibilityInjector(false);
392
393        if (!mAccessibilityScriptInjected) {
394            mAccessibilityScriptInjected = true;
395            final String injectionUrl = getScreenReaderInjectionUrl();
396            mWebView.loadUrl(injectionUrl);
397            if (DEBUG) {
398                Log.d(TAG, "[" + mWebView.hashCode() + "] Loading screen reader into WebView");
399            }
400        } else {
401            if (DEBUG) {
402                Log.w(TAG, "[" + mWebView.hashCode() + "] Attempted to inject screen reader twice");
403            }
404        }
405    }
406
407    /**
408     * Adjusts the accessibility injection state to reflect changes in the
409     * JavaScript enabled state.
410     *
411     * @param enabled Whether JavaScript is enabled.
412     */
413    public void updateJavaScriptEnabled(boolean enabled) {
414        if (enabled) {
415            addAccessibilityApisIfNecessary();
416        } else {
417            removeAccessibilityApisIfNecessary();
418        }
419
420        // We have to reload the page after adding or removing APIs.
421        mWebView.reload();
422    }
423
424    /**
425     * Toggles the non-JavaScript method for handling accessibility.
426     *
427     * @param enabled {@code true} to enable the non-JavaScript method, or
428     *            {@code false} to disable it.
429     */
430    private void toggleFallbackAccessibilityInjector(boolean enabled) {
431        if (enabled && (mAccessibilityInjectorFallback == null)) {
432            mAccessibilityInjectorFallback = new AccessibilityInjectorFallback(mWebViewClassic);
433        } else {
434            mAccessibilityInjectorFallback = null;
435        }
436    }
437
438    /**
439     * Determines whether it's okay to inject JavaScript into a given URL.
440     *
441     * @param url The URL to check.
442     * @return {@code true} if JavaScript should be injected, {@code false} if a
443     *         non-JavaScript method should be used.
444     */
445    private boolean shouldInjectJavaScript(String url) {
446        // Respect the WebView's JavaScript setting.
447        if (!isJavaScriptEnabled()) {
448            return false;
449        }
450
451        // Allow the page to opt out of Accessibility script injection.
452        if (getAxsUrlParameterValue(url) == ACCESSIBILITY_SCRIPT_INJECTION_OPTED_OUT) {
453            return false;
454        }
455
456        // The user must explicitly enable Accessibility script injection.
457        if (!isScriptInjectionEnabled()) {
458            return false;
459        }
460
461        return true;
462    }
463
464    /**
465     * @return {@code true} if the user has explicitly enabled Accessibility
466     *         script injection.
467     */
468    private boolean isScriptInjectionEnabled() {
469        final int injectionSetting = Settings.Secure.getInt(
470                mContext.getContentResolver(), Settings.Secure.ACCESSIBILITY_SCRIPT_INJECTION, 0);
471        return (injectionSetting == 1);
472    }
473
474    /**
475     * Attempts to initialize and add interfaces for TTS, if that hasn't already
476     * been done.
477     */
478    private void addTtsApis() {
479        if (mTextToSpeech == null) {
480            mTextToSpeech = new TextToSpeechWrapper(mContext);
481        }
482
483        mWebView.addJavascriptInterface(mTextToSpeech, ALIAS_TTS_JS_INTERFACE);
484    }
485
486    /**
487     * Attempts to shutdown and remove interfaces for TTS, if that hasn't
488     * already been done.
489     */
490    private void removeTtsApis() {
491        if (mTextToSpeech != null) {
492            mTextToSpeech.stop();
493            mTextToSpeech.shutdown();
494            mTextToSpeech = null;
495        }
496
497        mWebView.removeJavascriptInterface(ALIAS_TTS_JS_INTERFACE);
498    }
499
500    private void addCallbackApis() {
501        if (mCallback == null) {
502            mCallback = new CallbackHandler(ALIAS_TRAVERSAL_JS_INTERFACE);
503        }
504
505        mWebView.addJavascriptInterface(mCallback, ALIAS_TRAVERSAL_JS_INTERFACE);
506    }
507
508    private void removeCallbackApis() {
509        if (mCallback != null) {
510            mCallback = null;
511        }
512
513        mWebView.removeJavascriptInterface(ALIAS_TRAVERSAL_JS_INTERFACE);
514    }
515
516    /**
517     * Returns the script injection preference requested by the URL, or
518     * {@link #ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED} if the page has no
519     * preference.
520     *
521     * @param url The URL to check.
522     * @return A script injection preference.
523     */
524    private int getAxsUrlParameterValue(String url) {
525        if (url == null) {
526            return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED;
527        }
528
529        try {
530            final List<NameValuePair> params = URLEncodedUtils.parse(new URI(url), null);
531
532            for (NameValuePair param : params) {
533                if ("axs".equals(param.getName())) {
534                    return verifyInjectionValue(param.getValue());
535                }
536            }
537        } catch (URISyntaxException e) {
538            // Do nothing.
539        } catch (IllegalArgumentException e) {
540            // Catch badly-formed URLs.
541        }
542
543        return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED;
544    }
545
546    private int verifyInjectionValue(String value) {
547        try {
548            final int parsed = Integer.parseInt(value);
549
550            switch (parsed) {
551                case ACCESSIBILITY_SCRIPT_INJECTION_OPTED_OUT:
552                    return ACCESSIBILITY_SCRIPT_INJECTION_OPTED_OUT;
553                case ACCESSIBILITY_SCRIPT_INJECTION_PROVIDED:
554                    return ACCESSIBILITY_SCRIPT_INJECTION_PROVIDED;
555            }
556        } catch (NumberFormatException e) {
557            // Do nothing.
558        }
559
560        return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED;
561    }
562
563    /**
564     * @return The URL for injecting the screen reader.
565     */
566    private String getScreenReaderInjectionUrl() {
567        final String screenReaderUrl = Settings.Secure.getString(
568                mContext.getContentResolver(), Settings.Secure.ACCESSIBILITY_SCREEN_READER_URL);
569        return String.format(ACCESSIBILITY_SCREEN_READER_JAVASCRIPT_TEMPLATE, screenReaderUrl);
570    }
571
572    /**
573     * @return {@code true} if JavaScript is enabled in the {@link WebView}
574     *         settings.
575     */
576    private boolean isJavaScriptEnabled() {
577        final WebSettings settings = mWebView.getSettings();
578        if (settings == null) {
579            return false;
580        }
581
582        return settings.getJavaScriptEnabled();
583    }
584
585    /**
586     * @return {@code true} if accessibility is enabled.
587     */
588    private boolean isAccessibilityEnabled() {
589        return mAccessibilityManager.isEnabled();
590    }
591
592    /**
593     * Packs an accessibility action into a JSON object and sends it to AndroidVox.
594     *
595     * @param action The action identifier.
596     * @param arguments The action arguments, if applicable.
597     * @return The result of the action.
598     */
599    private boolean sendActionToAndroidVox(int action, Bundle arguments) {
600        if (mAccessibilityJSONObject == null) {
601            mAccessibilityJSONObject = new JSONObject();
602        } else {
603            // Remove all keys from the object.
604            final Iterator<?> keys = mAccessibilityJSONObject.keys();
605            while (keys.hasNext()) {
606                keys.next();
607                keys.remove();
608            }
609        }
610
611        try {
612            mAccessibilityJSONObject.accumulate("action", action);
613
614            switch (action) {
615                case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY:
616                case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY:
617                    if (arguments != null) {
618                        final int granularity = arguments.getInt(
619                                AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT);
620                        mAccessibilityJSONObject.accumulate("granularity", granularity);
621                    }
622                    break;
623                case AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT:
624                case AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT:
625                    if (arguments != null) {
626                        final String element = arguments.getString(
627                                AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING);
628                        mAccessibilityJSONObject.accumulate("element", element);
629                    }
630                    break;
631            }
632        } catch (JSONException e) {
633            return false;
634        }
635
636        final String jsonString = mAccessibilityJSONObject.toString();
637        final String jsCode = String.format(ACCESSIBILITY_ANDROIDVOX_TEMPLATE, jsonString);
638        return mCallback.performAction(mWebView, jsCode);
639    }
640
641    /**
642     * Used to protect the TextToSpeech class, only exposing the methods we want to expose.
643     */
644    private static class TextToSpeechWrapper {
645        private static final String WRAP_TAG = TextToSpeechWrapper.class.getSimpleName();
646
647        private final HashMap<String, String> mTtsParams;
648        private final TextToSpeech mTextToSpeech;
649
650        /**
651         * Whether this wrapper is ready to speak. If this is {@code true} then
652         * {@link #mShutdown} is guaranteed to be {@code false}.
653         */
654        private volatile boolean mReady;
655
656        /**
657         * Whether this wrapper was shut down. If this is {@code true} then
658         * {@link #mReady} is guaranteed to be {@code false}.
659         */
660        private volatile boolean mShutdown;
661
662        public TextToSpeechWrapper(Context context) {
663            if (DEBUG) {
664                Log.d(WRAP_TAG, "[" + hashCode() + "] Initializing text-to-speech on thread "
665                        + Thread.currentThread().getId() + "...");
666            }
667
668            final String pkgName = context.getPackageName();
669
670            mReady = false;
671            mShutdown = false;
672
673            mTtsParams = new HashMap<String, String>();
674            mTtsParams.put(Engine.KEY_PARAM_UTTERANCE_ID, WRAP_TAG);
675
676            mTextToSpeech = new TextToSpeech(
677                    context, mInitListener, null, pkgName + ".**webview**", true);
678            mTextToSpeech.setOnUtteranceProgressListener(mErrorListener);
679        }
680
681        @JavascriptInterface
682        @SuppressWarnings("unused")
683        public boolean isSpeaking() {
684            synchronized (mTextToSpeech) {
685                if (!mReady) {
686                    return false;
687                }
688
689                return mTextToSpeech.isSpeaking();
690            }
691        }
692
693        @JavascriptInterface
694        @SuppressWarnings("unused")
695        public int speak(String text, int queueMode, HashMap<String, String> params) {
696            synchronized (mTextToSpeech) {
697                if (!mReady) {
698                    if (DEBUG) {
699                        Log.w(WRAP_TAG, "[" + hashCode() + "] Attempted to speak before TTS init");
700                    }
701                    return TextToSpeech.ERROR;
702                } else {
703                    if (DEBUG) {
704                        Log.i(WRAP_TAG, "[" + hashCode() + "] Speak called from JS binder");
705                    }
706                }
707
708                return mTextToSpeech.speak(text, queueMode, params);
709            }
710        }
711
712        @JavascriptInterface
713        @SuppressWarnings("unused")
714        public int stop() {
715            synchronized (mTextToSpeech) {
716                if (!mReady) {
717                    if (DEBUG) {
718                        Log.w(WRAP_TAG, "[" + hashCode() + "] Attempted to stop before initialize");
719                    }
720                    return TextToSpeech.ERROR;
721                } else {
722                    if (DEBUG) {
723                        Log.i(WRAP_TAG, "[" + hashCode() + "] Stop called from JS binder");
724                    }
725                }
726
727                return mTextToSpeech.stop();
728            }
729        }
730
731        @SuppressWarnings("unused")
732        protected void shutdown() {
733            synchronized (mTextToSpeech) {
734                if (!mReady) {
735                    if (DEBUG) {
736                        Log.w(WRAP_TAG, "[" + hashCode() + "] Called shutdown before initialize");
737                    }
738                } else {
739                    if (DEBUG) {
740                        Log.i(WRAP_TAG, "[" + hashCode() + "] Shutting down text-to-speech from "
741                                + "thread " + Thread.currentThread().getId() + "...");
742                    }
743                }
744                mShutdown = true;
745                mReady = false;
746                mTextToSpeech.shutdown();
747            }
748        }
749
750        private final OnInitListener mInitListener = new OnInitListener() {
751            @Override
752            public void onInit(int status) {
753                synchronized (mTextToSpeech) {
754                    if (!mShutdown && (status == TextToSpeech.SUCCESS)) {
755                        if (DEBUG) {
756                            Log.d(WRAP_TAG, "[" + TextToSpeechWrapper.this.hashCode()
757                                    + "] Initialized successfully");
758                        }
759                        mReady = true;
760                    } else {
761                        if (DEBUG) {
762                            Log.w(WRAP_TAG, "[" + TextToSpeechWrapper.this.hashCode()
763                                    + "] Failed to initialize");
764                        }
765                        mReady = false;
766                    }
767                }
768            }
769        };
770
771        private final UtteranceProgressListener mErrorListener = new UtteranceProgressListener() {
772            @Override
773            public void onStart(String utteranceId) {
774                // Do nothing.
775            }
776
777            @Override
778            public void onError(String utteranceId) {
779                if (DEBUG) {
780                    Log.w(WRAP_TAG, "[" + TextToSpeechWrapper.this.hashCode()
781                            + "] Failed to speak utterance");
782                }
783            }
784
785            @Override
786            public void onDone(String utteranceId) {
787                // Do nothing.
788            }
789        };
790    }
791
792    /**
793     * Exposes result interface to JavaScript.
794     */
795    private static class CallbackHandler {
796        private static final String JAVASCRIPT_ACTION_TEMPLATE =
797                "javascript:(function() { %s.onResult(%d, %s); })();";
798
799        // Time in milliseconds to wait for a result before failing.
800        private static final long RESULT_TIMEOUT = 5000;
801
802        private final AtomicInteger mResultIdCounter = new AtomicInteger();
803        private final Object mResultLock = new Object();
804        private final String mInterfaceName;
805        private final Handler mMainHandler;
806
807        private Runnable mCallbackRunnable;
808
809        private boolean mResult = false;
810        private int mResultId = -1;
811
812        private CallbackHandler(String interfaceName) {
813            mInterfaceName = interfaceName;
814            mMainHandler = new Handler();
815        }
816
817        /**
818         * Performs an action and attempts to wait for a result.
819         *
820         * @param webView The WebView to perform the action on.
821         * @param code JavaScript code that evaluates to a result.
822         * @return The result of the action, or false if it timed out.
823         */
824        private boolean performAction(WebView webView, String code) {
825            final int resultId = mResultIdCounter.getAndIncrement();
826            final String url = String.format(
827                    JAVASCRIPT_ACTION_TEMPLATE, mInterfaceName, resultId, code);
828            webView.loadUrl(url);
829
830            return getResultAndClear(resultId);
831        }
832
833        /**
834         * Gets the result of a request to perform an accessibility action.
835         *
836         * @param resultId The result id to match the result with the request.
837         * @return The result of the request.
838         */
839        private boolean getResultAndClear(int resultId) {
840            synchronized (mResultLock) {
841                final boolean success = waitForResultTimedLocked(resultId);
842                final boolean result = success ? mResult : false;
843                clearResultLocked();
844                return result;
845            }
846        }
847
848        /**
849         * Clears the result state.
850         */
851        private void clearResultLocked() {
852            mResultId = -1;
853            mResult = false;
854        }
855
856        /**
857         * Waits up to a given bound for a result of a request and returns it.
858         *
859         * @param resultId The result id to match the result with the request.
860         * @return Whether the result was received.
861         */
862        private boolean waitForResultTimedLocked(int resultId) {
863            final long startTimeMillis = SystemClock.uptimeMillis();
864
865            if (DEBUG) {
866                Log.d(TAG, "Waiting for CVOX result with ID " + resultId + "...");
867            }
868
869            while (true) {
870                // Fail if we received a callback from the future.
871                if (mResultId > resultId) {
872                    if (DEBUG) {
873                        Log.w(TAG, "Aborted CVOX result");
874                    }
875                    return false;
876                }
877
878                final long elapsedTimeMillis = (SystemClock.uptimeMillis() - startTimeMillis);
879
880                // Succeed if we received the callback we were expecting.
881                if (DEBUG) {
882                    Log.w(TAG, "Check " + mResultId + " versus expected " + resultId);
883                }
884                if (mResultId == resultId) {
885                    if (DEBUG) {
886                        Log.w(TAG, "Received CVOX result after " + elapsedTimeMillis + " ms");
887                    }
888                    return true;
889                }
890
891                final long waitTimeMillis = (RESULT_TIMEOUT - elapsedTimeMillis);
892
893                // Fail if we've already exceeded the timeout.
894                if (waitTimeMillis <= 0) {
895                    if (DEBUG) {
896                        Log.w(TAG, "Timed out while waiting for CVOX result");
897                    }
898                    return false;
899                }
900
901                try {
902                    if (DEBUG) {
903                        Log.w(TAG, "Start waiting...");
904                    }
905                    mResultLock.wait(waitTimeMillis);
906                } catch (InterruptedException ie) {
907                    if (DEBUG) {
908                        Log.w(TAG, "Interrupted while waiting for CVOX result");
909                    }
910                }
911            }
912        }
913
914        /**
915         * Callback exposed to JavaScript. Handles returning the result of a
916         * request to a waiting (or potentially timed out) thread.
917         *
918         * @param id The result id of the request as a {@link String}.
919         * @param result The result of the request as a {@link String}.
920         */
921        @JavascriptInterface
922        @SuppressWarnings("unused")
923        public void onResult(String id, String result) {
924            if (DEBUG) {
925                Log.w(TAG, "Saw CVOX result of '" + result + "' for ID " + id);
926            }
927            final int resultId;
928
929            try {
930                resultId = Integer.parseInt(id);
931            } catch (NumberFormatException e) {
932                return;
933            }
934
935            synchronized (mResultLock) {
936                if (resultId > mResultId) {
937                    mResult = Boolean.parseBoolean(result);
938                    mResultId = resultId;
939                } else {
940                    if (DEBUG) {
941                        Log.w(TAG, "Result with ID " + resultId + " was stale vesus " + mResultId);
942                    }
943                }
944                mResultLock.notifyAll();
945            }
946        }
947
948        /**
949         * Requests a callback to ensure that the JavaScript interface for this
950         * object has been added successfully.
951         *
952         * @param webView The web view to request a callback from.
953         * @param callbackRunnable Runnable to execute if a callback is received.
954         */
955        public void requestCallback(WebView webView, Runnable callbackRunnable) {
956            mCallbackRunnable = callbackRunnable;
957
958            webView.loadUrl("javascript:(function() { " + mInterfaceName + ".callback(); })();");
959        }
960
961        @JavascriptInterface
962        @SuppressWarnings("unused")
963        public void callback() {
964            if (mCallbackRunnable != null) {
965                mMainHandler.post(mCallbackRunnable);
966                mCallbackRunnable = null;
967            }
968        }
969    }
970}
971