| 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 | |
| 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.provider.Settings; |
| 14 | import android.speech.tts.TextToSpeech; |
| 15 | import android.util.Log; |
| 16 | import android.view.View; |
| 17 | import android.view.accessibility.AccessibilityManager; |
| 18 | import android.view.accessibility.AccessibilityNodeInfo; |
| 19 | |
| 20 | import com.googlecode.eyesfree.braille.selfbraille.SelfBrailleClient; |
| 21 | import com.googlecode.eyesfree.braille.selfbraille.WriteData; |
| 22 | |
| 23 | import org.apache.http.NameValuePair; |
| 24 | import org.apache.http.client.utils.URLEncodedUtils; |
| 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.CommandLine; |
| 29 | import org.json.JSONException; |
| 30 | import org.json.JSONObject; |
| 31 | |
| 32 | import java.lang.reflect.Field; |
| 33 | import java.net.URI; |
| 34 | import java.net.URISyntaxException; |
| 35 | import java.util.HashMap; |
| 36 | import java.util.Iterator; |
| 37 | import java.util.List; |
| 38 | |
| 39 | /** |
| 40 | * Responsible for accessibility injection and management of a {@link ContentViewCore}. |
| 41 | */ |
| 42 | public class AccessibilityInjector extends WebContentsObserverAndroid { |
| 43 | private static final String TAG = "AccessibilityInjector"; |
| 44 | |
| 45 | // The ContentView this injector is responsible for managing. |
| 46 | protected ContentViewCore mContentViewCore; |
| 47 | |
| 48 | // The Java objects that are exposed to JavaScript |
| 49 | private TextToSpeechWrapper mTextToSpeech; |
| 50 | private VibratorWrapper mVibrator; |
| 51 | private final boolean mHasVibratePermission; |
| 52 | |
| 53 | // Lazily loaded helper objects. |
| 54 | private AccessibilityManager mAccessibilityManager; |
| 55 | |
| 56 | // Whether or not we should be injecting the script. |
| 57 | protected boolean mInjectedScriptEnabled; |
| 58 | protected boolean mScriptInjected; |
| 59 | |
| 60 | private final String mAccessibilityScreenReaderUrl; |
| 61 | |
| 62 | // To support building against the JELLY_BEAN and not JELLY_BEAN_MR1 SDK we need to add this |
| 63 | // constant here. |
| 64 | private static final int FEEDBACK_BRAILLE = 0x00000020; |
| 65 | |
| 66 | // constants for determining script injection strategy |
| 67 | private static final int ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED = -1; |
| 68 | private static final int ACCESSIBILITY_SCRIPT_INJECTION_OPTED_OUT = 0; |
| 69 | private static final int ACCESSIBILITY_SCRIPT_INJECTION_PROVIDED = 1; |
| 70 | private static final String ALIAS_ACCESSIBILITY_JS_INTERFACE = "accessibility"; |
| 71 | private static final String ALIAS_ACCESSIBILITY_JS_INTERFACE_2 = "accessibility2"; |
| 72 | |
| 73 | // Template for JavaScript that injects a screen-reader. |
| 74 | private static final String DEFAULT_ACCESSIBILITY_SCREEN_READER_URL = |
| 75 | "https://ssl.gstatic.com/accessibility/javascript/android/chromeandroidvox.js"; |
| 76 | |
| 77 | private static final String ACCESSIBILITY_SCREEN_READER_JAVASCRIPT_TEMPLATE = |
| 78 | "(function() {" + |
| 79 | " var chooser = document.createElement('script');" + |
| 80 | " chooser.type = 'text/javascript';" + |
| 81 | " chooser.src = '%1s';" + |
| 82 | " document.getElementsByTagName('head')[0].appendChild(chooser);" + |
| 83 | " })();"; |
| 84 | |
| 85 | // JavaScript call to turn ChromeVox on or off. |
| 86 | private static final String TOGGLE_CHROME_VOX_JAVASCRIPT = |
| 87 | "(function() {" + |
| 88 | " if (typeof cvox !== 'undefined') {" + |
| 89 | " cvox.ChromeVox.host.activateOrDeactivateChromeVox(%1s);" + |
| 90 | " }" + |
| 91 | " })();"; |
| 92 | |
| 93 | /** |
| 94 | * Returns an instance of the {@link AccessibilityInjector} based on the SDK version. |
| 95 | * @param view The ContentViewCore that this AccessibilityInjector manages. |
| 96 | * @return An instance of a {@link AccessibilityInjector}. |
| 97 | */ |
| 98 | public static AccessibilityInjector newInstance(ContentViewCore view) { |
| 99 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) { |
| 100 | return new AccessibilityInjector(view); |
| 101 | } else { |
| 102 | return new JellyBeanAccessibilityInjector(view); |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | /** |
| 107 | * Creates an instance of the IceCreamSandwichAccessibilityInjector. |
| 108 | * @param view The ContentViewCore that this AccessibilityInjector manages. |
| 109 | */ |
| 110 | protected AccessibilityInjector(ContentViewCore view) { |
| 111 | super(view); |
| 112 | mContentViewCore = view; |
| 113 | |
| 114 | mAccessibilityScreenReaderUrl = CommandLine.getInstance().getSwitchValue( |
| 115 | CommandLine.ACCESSIBILITY_JAVASCRIPT_URL, 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 | */ |
| 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 | */ |
| 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 | */ |
| 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 | */ |
| 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 |
| 217 | public void didStartLoading(String url) { |
| 218 | mScriptInjected = false; |
| 219 | } |
| 220 | |
| 221 | @Override |
| 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 | */ |
| 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 | */ |
| 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 | */ |
| 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 | */ |
| 269 | public boolean performAccessibilityAction(int action, Bundle arguments) { |
| 270 | return false; |
| 271 | } |
| 272 | |
| 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 | |
| 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 | |
| 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 | |
| 327 | private String getScreenReaderInjectingJs() { |
| 328 | return String.format(ACCESSIBILITY_SCREEN_READER_JAVASCRIPT_TEMPLATE, |
| 329 | mAccessibilityScreenReaderUrl); |
| 330 | } |
| 331 | |
| 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 | |
| 352 | public VibratorWrapper(Context context) { |
| 353 | mVibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); |
| 354 | } |
| 355 | |
| 356 | @JavascriptInterface |
| 357 | @SuppressWarnings("unused") |
| 358 | public boolean hasVibrator() { |
| 359 | return mVibrator.hasVibrator(); |
| 360 | } |
| 361 | |
| 362 | @JavascriptInterface |
| 363 | @SuppressWarnings("unused") |
| 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") |
| 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") |
| 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 | |
| 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 | CommandLine.ACCESSIBILITY_DEBUG_BRAILLE_SERVICE)); |
| 401 | } |
| 402 | |
| 403 | @JavascriptInterface |
| 404 | @SuppressWarnings("unused") |
| 405 | public boolean isSpeaking() { |
| 406 | return mTextToSpeech.isSpeaking(); |
| 407 | } |
| 408 | |
| 409 | @JavascriptInterface |
| 410 | @SuppressWarnings("unused") |
| 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") |
| 440 | public int stop() { |
| 441 | return mTextToSpeech.stop(); |
| 442 | } |
| 443 | |
| 444 | @JavascriptInterface |
| 445 | @SuppressWarnings("unused") |
| 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") |
| 461 | protected void shutdownInternal() { |
| 462 | mTextToSpeech.shutdown(); |
| 463 | mSelfBrailleClient.shutdown(); |
| 464 | } |
| 465 | } |
| 466 | } |