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