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