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