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