1 // Copyright 2012 The Chromium Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style license that can be 3 // found in the LICENSE file. 4 5 package org.chromium.content.browser.accessibility; 6 7 import android.accessibilityservice.AccessibilityServiceInfo; 8 import android.content.Context; 9 import android.content.pm.PackageManager; 10 import android.os.Build; 11 import android.os.Bundle; 12 import android.os.Vibrator; 13 import android.speech.tts.TextToSpeech; 14 import android.util.Log; 15 import android.view.View; 16 import android.view.accessibility.AccessibilityManager; 17 import android.view.accessibility.AccessibilityNodeInfo; 18 19 import com.googlecode.eyesfree.braille.selfbraille.SelfBrailleClient; 20 import com.googlecode.eyesfree.braille.selfbraille.WriteData; 21 22 import org.apache.http.NameValuePair; 23 import org.apache.http.client.utils.URLEncodedUtils; 24 import org.chromium.base.CommandLine; 25 import org.chromium.content.browser.ContentViewCore; 26 import org.chromium.content.browser.JavascriptInterface; 27 import org.chromium.content.browser.WebContentsObserverAndroid; 28 import org.chromium.content.common.ContentSwitches; 29 import org.json.JSONException; 30 import org.json.JSONObject; 31 32 import java.net.URI; 33 import java.net.URISyntaxException; 34 import java.util.HashMap; 35 import java.util.Iterator; 36 import java.util.List; 37 38 /** 39 * Responsible for accessibility injection and management of a {@link ContentViewCore}. 40 */ 41 public class AccessibilityInjector extends WebContentsObserverAndroid { 42 private static final String TAG = "AccessibilityInjector"; 43 44 // The ContentView this injector is responsible for managing. 45 protected ContentViewCore mContentViewCore; 46 47 // The Java objects that are exposed to JavaScript 48 private TextToSpeechWrapper mTextToSpeech; 49 private VibratorWrapper mVibrator; 50 private final boolean mHasVibratePermission; 51 52 // Lazily loaded helper objects. 53 private AccessibilityManager mAccessibilityManager; 54 55 // Whether or not we should be injecting the script. 56 protected boolean mInjectedScriptEnabled; 57 protected boolean mScriptInjected; 58 59 private final String mAccessibilityScreenReaderUrl; 60 61 // To support building against the JELLY_BEAN and not JELLY_BEAN_MR1 SDK we need to add this 62 // constant here. 63 private static final int FEEDBACK_BRAILLE = 0x00000020; 64 65 // constants for determining script injection strategy 66 private static final int ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED = -1; 67 private static final int ACCESSIBILITY_SCRIPT_INJECTION_OPTED_OUT = 0; 68 private static final int ACCESSIBILITY_SCRIPT_INJECTION_PROVIDED = 1; 69 private static final String ALIAS_ACCESSIBILITY_JS_INTERFACE = "accessibility"; 70 private static final String ALIAS_ACCESSIBILITY_JS_INTERFACE_2 = "accessibility2"; 71 72 // Template for JavaScript that injects a screen-reader. 73 private static final String DEFAULT_ACCESSIBILITY_SCREEN_READER_URL = 74 "https://ssl.gstatic.com/accessibility/javascript/android/chromeandroidvox.js"; 75 76 private static final String ACCESSIBILITY_SCREEN_READER_JAVASCRIPT_TEMPLATE = 77 "(function() {" + 78 " var chooser = document.createElement('script');" + 79 " chooser.type = 'text/javascript';" + 80 " chooser.src = '%1s';" + 81 " document.getElementsByTagName('head')[0].appendChild(chooser);" + 82 " })();"; 83 84 // JavaScript call to turn ChromeVox on or off. 85 private static final String TOGGLE_CHROME_VOX_JAVASCRIPT = 86 "(function() {" + 87 " if (typeof cvox !== 'undefined') {" + 88 " cvox.ChromeVox.host.activateOrDeactivateChromeVox(%1s);" + 89 " }" + 90 " })();"; 91 92 /** 93 * Returns an instance of the {@link AccessibilityInjector} based on the SDK version. 94 * @param view The ContentViewCore that this AccessibilityInjector manages. 95 * @return An instance of a {@link AccessibilityInjector}. 96 */ newInstance(ContentViewCore view)97 public static AccessibilityInjector newInstance(ContentViewCore view) { 98 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) { 99 return new AccessibilityInjector(view); 100 } else { 101 return new JellyBeanAccessibilityInjector(view); 102 } 103 } 104 105 /** 106 * Creates an instance of the IceCreamSandwichAccessibilityInjector. 107 * @param view The ContentViewCore that this AccessibilityInjector manages. 108 */ AccessibilityInjector(ContentViewCore view)109 protected AccessibilityInjector(ContentViewCore view) { 110 super(view.getWebContents()); 111 mContentViewCore = view; 112 113 mAccessibilityScreenReaderUrl = CommandLine.getInstance().getSwitchValue( 114 ContentSwitches.ACCESSIBILITY_JAVASCRIPT_URL, 115 DEFAULT_ACCESSIBILITY_SCREEN_READER_URL); 116 117 mHasVibratePermission = mContentViewCore.getContext().checkCallingOrSelfPermission( 118 android.Manifest.permission.VIBRATE) == PackageManager.PERMISSION_GRANTED; 119 } 120 121 /** 122 * Injects a <script> tag into the current web site that pulls in the ChromeVox script for 123 * accessibility support. Only injects if accessibility is turned on by 124 * {@link AccessibilityManager#isEnabled()}, accessibility script injection is turned on, and 125 * javascript is enabled on this page. 126 * 127 * @see AccessibilityManager#isEnabled() 128 */ injectAccessibilityScriptIntoPage()129 public void injectAccessibilityScriptIntoPage() { 130 if (!accessibilityIsAvailable()) return; 131 132 int axsParameterValue = getAxsUrlParameterValue(); 133 if (axsParameterValue != ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED) { 134 return; 135 } 136 137 String js = getScreenReaderInjectingJs(); 138 if (mContentViewCore.isDeviceAccessibilityScriptInjectionEnabled() && 139 js != null && mContentViewCore.isAlive()) { 140 addOrRemoveAccessibilityApisIfNecessary(); 141 mContentViewCore.evaluateJavaScript(js, null); 142 mInjectedScriptEnabled = true; 143 mScriptInjected = true; 144 } 145 } 146 147 /** 148 * Handles adding or removing accessibility related Java objects ({@link TextToSpeech} and 149 * {@link Vibrator}) interfaces from Javascript. This method should be called at a time when it 150 * is safe to add or remove these interfaces, specifically when the {@link ContentViewCore} is 151 * first initialized or right before the {@link ContentViewCore} is about to navigate to a URL 152 * or reload. 153 * <p> 154 * If this method is called at other times, the interfaces might not be correctly removed, 155 * meaning that Javascript can still access these Java objects that may have been already 156 * shut down. 157 */ addOrRemoveAccessibilityApisIfNecessary()158 public void addOrRemoveAccessibilityApisIfNecessary() { 159 if (accessibilityIsAvailable()) { 160 addAccessibilityApis(); 161 } else { 162 removeAccessibilityApis(); 163 } 164 } 165 166 /** 167 * Checks whether or not touch to explore is enabled on the system. 168 */ accessibilityIsAvailable()169 public boolean accessibilityIsAvailable() { 170 if (!getAccessibilityManager().isEnabled() || 171 mContentViewCore.getContentSettings() == null || 172 !mContentViewCore.getContentSettings().getJavaScriptEnabled()) { 173 return false; 174 } 175 176 try { 177 // Check that there is actually a service running that requires injecting this script. 178 List<AccessibilityServiceInfo> services = 179 getAccessibilityManager().getEnabledAccessibilityServiceList( 180 FEEDBACK_BRAILLE | AccessibilityServiceInfo.FEEDBACK_SPOKEN); 181 return services.size() > 0; 182 } catch (NullPointerException e) { 183 // getEnabledAccessibilityServiceList() can throw an NPE due to a bad 184 // AccessibilityService. 185 return false; 186 } 187 } 188 189 /** 190 * Sets whether or not the script is enabled. If the script is disabled, we also stop any 191 * we output that is occurring. If the script has not yet been injected, injects it. 192 * @param enabled Whether or not to enable the script. 193 */ setScriptEnabled(boolean enabled)194 public void setScriptEnabled(boolean enabled) { 195 if (enabled && !mScriptInjected) injectAccessibilityScriptIntoPage(); 196 if (!accessibilityIsAvailable() || mInjectedScriptEnabled == enabled) return; 197 198 mInjectedScriptEnabled = enabled; 199 if (mContentViewCore.isAlive()) { 200 String js = String.format(TOGGLE_CHROME_VOX_JAVASCRIPT, Boolean.toString( 201 mInjectedScriptEnabled)); 202 mContentViewCore.evaluateJavaScript(js, null); 203 204 if (!mInjectedScriptEnabled) { 205 // Stop any TTS/Vibration right now. 206 onPageLostFocus(); 207 } 208 } 209 } 210 211 /** 212 * Notifies this handler that a page load has started, which means we should mark the 213 * accessibility script as not being injected. This way we can properly ignore incoming 214 * accessibility gesture events. 215 */ 216 @Override didStartLoading(String url)217 public void didStartLoading(String url) { 218 mScriptInjected = false; 219 } 220 221 @Override didStopLoading(String url)222 public void didStopLoading(String url) { 223 injectAccessibilityScriptIntoPage(); 224 } 225 226 /** 227 * Stop any notifications that are currently going on (e.g. Text-to-Speech). 228 */ onPageLostFocus()229 public void onPageLostFocus() { 230 if (mContentViewCore.isAlive()) { 231 if (mTextToSpeech != null) mTextToSpeech.stop(); 232 if (mVibrator != null) mVibrator.cancel(); 233 } 234 } 235 236 /** 237 * Initializes an {@link AccessibilityNodeInfo} with the actions and movement granularity 238 * levels supported by this {@link AccessibilityInjector}. 239 * <p> 240 * If an action identifier is added in this method, this {@link AccessibilityInjector} should 241 * also return {@code true} from {@link #supportsAccessibilityAction(int)}. 242 * </p> 243 * 244 * @param info The info to initialize. 245 * @see View#onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo) 246 */ onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info)247 public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { } 248 249 /** 250 * Returns {@code true} if this {@link AccessibilityInjector} should handle the specified 251 * action. 252 * 253 * @param action An accessibility action identifier. 254 * @return {@code true} if this {@link AccessibilityInjector} should handle the specified 255 * action. 256 */ supportsAccessibilityAction(int action)257 public boolean supportsAccessibilityAction(int action) { 258 return false; 259 } 260 261 /** 262 * Performs the specified accessibility action. 263 * 264 * @param action The identifier of the action to perform. 265 * @param arguments The action arguments, or {@code null} if no arguments. 266 * @return {@code true} if the action was successful. 267 * @see View#performAccessibilityAction(int, Bundle) 268 */ performAccessibilityAction(int action, Bundle arguments)269 public boolean performAccessibilityAction(int action, Bundle arguments) { 270 return false; 271 } 272 addAccessibilityApis()273 protected void addAccessibilityApis() { 274 Context context = mContentViewCore.getContext(); 275 if (context != null) { 276 // Enabled, we should try to add if we have to. 277 if (mTextToSpeech == null) { 278 mTextToSpeech = new TextToSpeechWrapper(mContentViewCore.getContainerView(), 279 context); 280 mContentViewCore.addJavascriptInterface(mTextToSpeech, 281 ALIAS_ACCESSIBILITY_JS_INTERFACE); 282 } 283 284 if (mVibrator == null && mHasVibratePermission) { 285 mVibrator = new VibratorWrapper(context); 286 mContentViewCore.addJavascriptInterface(mVibrator, 287 ALIAS_ACCESSIBILITY_JS_INTERFACE_2); 288 } 289 } 290 } 291 removeAccessibilityApis()292 protected void removeAccessibilityApis() { 293 if (mTextToSpeech != null) { 294 mContentViewCore.removeJavascriptInterface(ALIAS_ACCESSIBILITY_JS_INTERFACE); 295 mTextToSpeech.stop(); 296 mTextToSpeech.shutdownInternal(); 297 mTextToSpeech = null; 298 } 299 300 if (mVibrator != null) { 301 mContentViewCore.removeJavascriptInterface(ALIAS_ACCESSIBILITY_JS_INTERFACE_2); 302 mVibrator.cancel(); 303 mVibrator = null; 304 } 305 } 306 getAxsUrlParameterValue()307 private int getAxsUrlParameterValue() { 308 if (mContentViewCore.getWebContents().getUrl() == null) { 309 return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED; 310 } 311 312 try { 313 List<NameValuePair> params = URLEncodedUtils.parse( 314 new URI(mContentViewCore.getWebContents().getUrl()), null); 315 316 for (NameValuePair param : params) { 317 if ("axs".equals(param.getName())) { 318 return Integer.parseInt(param.getValue()); 319 } 320 } 321 } catch (URISyntaxException ex) { 322 // Intentional no-op. 323 } catch (NumberFormatException ex) { 324 // Intentional no-op. 325 } catch (IllegalArgumentException ex) { 326 // Intentional no-op. 327 } 328 329 return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED; 330 } 331 getScreenReaderInjectingJs()332 private String getScreenReaderInjectingJs() { 333 return String.format(ACCESSIBILITY_SCREEN_READER_JAVASCRIPT_TEMPLATE, 334 mAccessibilityScreenReaderUrl); 335 } 336 getAccessibilityManager()337 private AccessibilityManager getAccessibilityManager() { 338 if (mAccessibilityManager == null) { 339 mAccessibilityManager = (AccessibilityManager) mContentViewCore.getContext(). 340 getSystemService(Context.ACCESSIBILITY_SERVICE); 341 } 342 343 return mAccessibilityManager; 344 } 345 346 /** 347 * Used to protect how long JavaScript can vibrate for. This isn't a good comprehensive 348 * protection, just used to cover mistakes and protect against long vibrate durations/repeats. 349 * 350 * Also only exposes methods we *want* to expose, no others for the class. 351 */ 352 private static class VibratorWrapper { 353 private static final long MAX_VIBRATE_DURATION_MS = 5000; 354 355 private final Vibrator mVibrator; 356 VibratorWrapper(Context context)357 public VibratorWrapper(Context context) { 358 mVibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); 359 } 360 361 @JavascriptInterface 362 @SuppressWarnings("unused") hasVibrator()363 public boolean hasVibrator() { 364 return mVibrator.hasVibrator(); 365 } 366 367 @JavascriptInterface 368 @SuppressWarnings("unused") vibrate(long milliseconds)369 public void vibrate(long milliseconds) { 370 milliseconds = Math.min(milliseconds, MAX_VIBRATE_DURATION_MS); 371 mVibrator.vibrate(milliseconds); 372 } 373 374 @JavascriptInterface 375 @SuppressWarnings("unused") vibrate(long[] pattern, int repeat)376 public void vibrate(long[] pattern, int repeat) { 377 for (int i = 0; i < pattern.length; ++i) { 378 pattern[i] = Math.min(pattern[i], MAX_VIBRATE_DURATION_MS); 379 } 380 381 repeat = -1; 382 383 mVibrator.vibrate(pattern, repeat); 384 } 385 386 @JavascriptInterface 387 @SuppressWarnings("unused") cancel()388 public void cancel() { 389 mVibrator.cancel(); 390 } 391 } 392 393 /** 394 * Used to protect the TextToSpeech class, only exposing the methods we want to expose. 395 */ 396 private static class TextToSpeechWrapper { 397 private final TextToSpeech mTextToSpeech; 398 private final SelfBrailleClient mSelfBrailleClient; 399 private final View mView; 400 TextToSpeechWrapper(View view, Context context)401 public TextToSpeechWrapper(View view, Context context) { 402 mView = view; 403 mTextToSpeech = new TextToSpeech(context, null, null); 404 mSelfBrailleClient = new SelfBrailleClient(context, CommandLine.getInstance().hasSwitch( 405 ContentSwitches.ACCESSIBILITY_DEBUG_BRAILLE_SERVICE)); 406 } 407 408 @JavascriptInterface 409 @SuppressWarnings("unused") isSpeaking()410 public boolean isSpeaking() { 411 return mTextToSpeech.isSpeaking(); 412 } 413 414 @JavascriptInterface 415 @SuppressWarnings("unused") speak(String text, int queueMode, String jsonParams)416 public int speak(String text, int queueMode, String jsonParams) { 417 // Try to pull the params from the JSON string. 418 HashMap<String, String> params = null; 419 try { 420 if (jsonParams != null) { 421 params = new HashMap<String, String>(); 422 JSONObject json = new JSONObject(jsonParams); 423 424 // Using legacy API here. 425 @SuppressWarnings("unchecked") 426 Iterator<String> keyIt = json.keys(); 427 428 while (keyIt.hasNext()) { 429 String key = keyIt.next(); 430 // Only add parameters that are raw data types. 431 if (json.optJSONObject(key) == null && json.optJSONArray(key) == null) { 432 params.put(key, json.getString(key)); 433 } 434 } 435 } 436 } catch (JSONException e) { 437 params = null; 438 } 439 440 return mTextToSpeech.speak(text, queueMode, params); 441 } 442 443 @JavascriptInterface 444 @SuppressWarnings("unused") stop()445 public int stop() { 446 return mTextToSpeech.stop(); 447 } 448 449 @JavascriptInterface 450 @SuppressWarnings("unused") braille(String jsonString)451 public void braille(String jsonString) { 452 try { 453 JSONObject jsonObj = new JSONObject(jsonString); 454 455 WriteData data = WriteData.forView(mView); 456 data.setText(jsonObj.getString("text")); 457 data.setSelectionStart(jsonObj.getInt("startIndex")); 458 data.setSelectionEnd(jsonObj.getInt("endIndex")); 459 mSelfBrailleClient.write(data); 460 } catch (JSONException ex) { 461 Log.w(TAG, "Error parsing JS JSON object", ex); 462 } 463 } 464 465 @SuppressWarnings("unused") shutdownInternal()466 protected void shutdownInternal() { 467 mTextToSpeech.shutdown(); 468 mSelfBrailleClient.shutdown(); 469 } 470 } 471 } 472