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