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