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); 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.getUrl() == null) return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED; 309 310 try { 311 List<NameValuePair> params = URLEncodedUtils.parse(new URI(mContentViewCore.getUrl()), 312 null); 313 314 for (NameValuePair param : params) { 315 if ("axs".equals(param.getName())) { 316 return Integer.parseInt(param.getValue()); 317 } 318 } 319 } catch (URISyntaxException ex) { 320 } catch (NumberFormatException ex) { 321 } catch (IllegalArgumentException ex) { 322 } 323 324 return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED; 325 } 326 getScreenReaderInjectingJs()327 private String getScreenReaderInjectingJs() { 328 return String.format(ACCESSIBILITY_SCREEN_READER_JAVASCRIPT_TEMPLATE, 329 mAccessibilityScreenReaderUrl); 330 } 331 getAccessibilityManager()332 private AccessibilityManager getAccessibilityManager() { 333 if (mAccessibilityManager == null) { 334 mAccessibilityManager = (AccessibilityManager) mContentViewCore.getContext(). 335 getSystemService(Context.ACCESSIBILITY_SERVICE); 336 } 337 338 return mAccessibilityManager; 339 } 340 341 /** 342 * Used to protect how long JavaScript can vibrate for. This isn't a good comprehensive 343 * protection, just used to cover mistakes and protect against long vibrate durations/repeats. 344 * 345 * Also only exposes methods we *want* to expose, no others for the class. 346 */ 347 private static class VibratorWrapper { 348 private static final long MAX_VIBRATE_DURATION_MS = 5000; 349 350 private Vibrator mVibrator; 351 VibratorWrapper(Context context)352 public VibratorWrapper(Context context) { 353 mVibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); 354 } 355 356 @JavascriptInterface 357 @SuppressWarnings("unused") hasVibrator()358 public boolean hasVibrator() { 359 return mVibrator.hasVibrator(); 360 } 361 362 @JavascriptInterface 363 @SuppressWarnings("unused") vibrate(long milliseconds)364 public void vibrate(long milliseconds) { 365 milliseconds = Math.min(milliseconds, MAX_VIBRATE_DURATION_MS); 366 mVibrator.vibrate(milliseconds); 367 } 368 369 @JavascriptInterface 370 @SuppressWarnings("unused") vibrate(long[] pattern, int repeat)371 public void vibrate(long[] pattern, int repeat) { 372 for (int i = 0; i < pattern.length; ++i) { 373 pattern[i] = Math.min(pattern[i], MAX_VIBRATE_DURATION_MS); 374 } 375 376 repeat = -1; 377 378 mVibrator.vibrate(pattern, repeat); 379 } 380 381 @JavascriptInterface 382 @SuppressWarnings("unused") cancel()383 public void cancel() { 384 mVibrator.cancel(); 385 } 386 } 387 388 /** 389 * Used to protect the TextToSpeech class, only exposing the methods we want to expose. 390 */ 391 private static class TextToSpeechWrapper { 392 private TextToSpeech mTextToSpeech; 393 private SelfBrailleClient mSelfBrailleClient; 394 private View mView; 395 TextToSpeechWrapper(View view, Context context)396 public TextToSpeechWrapper(View view, Context context) { 397 mView = view; 398 mTextToSpeech = new TextToSpeech(context, null, null); 399 mSelfBrailleClient = new SelfBrailleClient(context, CommandLine.getInstance().hasSwitch( 400 ContentSwitches.ACCESSIBILITY_DEBUG_BRAILLE_SERVICE)); 401 } 402 403 @JavascriptInterface 404 @SuppressWarnings("unused") isSpeaking()405 public boolean isSpeaking() { 406 return mTextToSpeech.isSpeaking(); 407 } 408 409 @JavascriptInterface 410 @SuppressWarnings("unused") speak(String text, int queueMode, String jsonParams)411 public int speak(String text, int queueMode, String jsonParams) { 412 // Try to pull the params from the JSON string. 413 HashMap<String, String> params = null; 414 try { 415 if (jsonParams != null) { 416 params = new HashMap<String, String>(); 417 JSONObject json = new JSONObject(jsonParams); 418 419 // Using legacy API here. 420 @SuppressWarnings("unchecked") 421 Iterator<String> keyIt = json.keys(); 422 423 while (keyIt.hasNext()) { 424 String key = keyIt.next(); 425 // Only add parameters that are raw data types. 426 if (json.optJSONObject(key) == null && json.optJSONArray(key) == null) { 427 params.put(key, json.getString(key)); 428 } 429 } 430 } 431 } catch (JSONException e) { 432 params = null; 433 } 434 435 return mTextToSpeech.speak(text, queueMode, params); 436 } 437 438 @JavascriptInterface 439 @SuppressWarnings("unused") stop()440 public int stop() { 441 return mTextToSpeech.stop(); 442 } 443 444 @JavascriptInterface 445 @SuppressWarnings("unused") braille(String jsonString)446 public void braille(String jsonString) { 447 try { 448 JSONObject jsonObj = new JSONObject(jsonString); 449 450 WriteData data = WriteData.forView(mView); 451 data.setText(jsonObj.getString("text")); 452 data.setSelectionStart(jsonObj.getInt("startIndex")); 453 data.setSelectionEnd(jsonObj.getInt("endIndex")); 454 mSelfBrailleClient.write(data); 455 } catch (JSONException ex) { 456 Log.w(TAG, "Error parsing JS JSON object", ex); 457 } 458 } 459 460 @SuppressWarnings("unused") shutdownInternal()461 protected void shutdownInternal() { 462 mTextToSpeech.shutdown(); 463 mSelfBrailleClient.shutdown(); 464 } 465 } 466 } 467