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.
5 package org.chromium.content.browser.accessibility;
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;
19 import com.googlecode.eyesfree.braille.selfbraille.SelfBrailleClient;
20 import com.googlecode.eyesfree.braille.selfbraille.WriteData;
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;
33 import java.net.URISyntaxException;
34 import java.util.HashMap;
35 import java.util.Iterator;
36 import java.util.List;
39 * Responsible for accessibility injection and management of a {@link ContentViewCore}.
41 public class AccessibilityInjector extends WebContentsObserverAndroid {
42 private static final String TAG = "AccessibilityInjector";
44 // The ContentView this injector is responsible for managing.
45 protected ContentViewCore mContentViewCore;
47 // The Java objects that are exposed to JavaScript
48 private TextToSpeechWrapper mTextToSpeech;
49 private VibratorWrapper mVibrator;
50 private final boolean mHasVibratePermission;
52 // Lazily loaded helper objects.
53 private AccessibilityManager mAccessibilityManager;
55 // Whether or not we should be injecting the script.
56 protected boolean mInjectedScriptEnabled;
57 protected boolean mScriptInjected;
59 private final String mAccessibilityScreenReaderUrl;
61 // To support building against the JELLY_BEAN and not JELLY_BEAN_MR1 SDK we need to add this
63 private static final int FEEDBACK_BRAILLE = 0x00000020;
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";
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";
76 private static final String ACCESSIBILITY_SCREEN_READER_JAVASCRIPT_TEMPLATE =
78 " var chooser = document.createElement('script');" +
79 " chooser.type = 'text/javascript';" +
80 " chooser.src = '%1s';" +
81 " document.getElementsByTagName('head')[0].appendChild(chooser);" +
84 // JavaScript call to turn ChromeVox on or off.
85 private static final String TOGGLE_CHROME_VOX_JAVASCRIPT =
87 " if (typeof cvox !== 'undefined') {" +
88 " cvox.ChromeVox.host.activateOrDeactivateChromeVox(%1s);" +
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}.
97 public static AccessibilityInjector newInstance(ContentViewCore view) {
98 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) {
99 return new AccessibilityInjector(view);
101 return new JellyBeanAccessibilityInjector(view);
106 * Creates an instance of the IceCreamSandwichAccessibilityInjector.
107 * @param view The ContentViewCore that this AccessibilityInjector manages.
109 protected AccessibilityInjector(ContentViewCore view) {
110 super(view.getWebContents());
111 mContentViewCore = view;
113 mAccessibilityScreenReaderUrl = CommandLine.getInstance().getSwitchValue(
114 ContentSwitches.ACCESSIBILITY_JAVASCRIPT_URL,
115 DEFAULT_ACCESSIBILITY_SCREEN_READER_URL);
117 mHasVibratePermission = mContentViewCore.getContext().checkCallingOrSelfPermission(
118 android.Manifest.permission.VIBRATE) == PackageManager.PERMISSION_GRANTED;
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.
127 * @see AccessibilityManager#isEnabled()
129 public void injectAccessibilityScriptIntoPage() {
130 if (!accessibilityIsAvailable()) return;
132 int axsParameterValue = getAxsUrlParameterValue();
133 if (axsParameterValue != ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED) {
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;
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
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
158 public void addOrRemoveAccessibilityApisIfNecessary() {
159 if (accessibilityIsAvailable()) {
160 addAccessibilityApis();
162 removeAccessibilityApis();
167 * Checks whether or not touch to explore is enabled on the system.
169 public boolean accessibilityIsAvailable() {
170 if (!getAccessibilityManager().isEnabled() ||
171 mContentViewCore.getContentSettings() == null ||
172 !mContentViewCore.getContentSettings().getJavaScriptEnabled()) {
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.
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.
194 public void setScriptEnabled(boolean enabled) {
195 if (enabled && !mScriptInjected) injectAccessibilityScriptIntoPage();
196 if (!accessibilityIsAvailable() || mInjectedScriptEnabled == enabled) return;
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);
204 if (!mInjectedScriptEnabled) {
205 // Stop any TTS/Vibration right now.
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.
217 public void didStartLoading(String url) {
218 mScriptInjected = false;
222 public void didStopLoading(String url) {
223 injectAccessibilityScriptIntoPage();
227 * Stop any notifications that are currently going on (e.g. Text-to-Speech).
229 public void onPageLostFocus() {
230 if (mContentViewCore.isAlive()) {
231 if (mTextToSpeech != null) mTextToSpeech.stop();
232 if (mVibrator != null) mVibrator.cancel();
237 * Initializes an {@link AccessibilityNodeInfo} with the actions and movement granularity
238 * levels supported by this {@link AccessibilityInjector}.
240 * If an action identifier is added in this method, this {@link AccessibilityInjector} should
241 * also return {@code true} from {@link #supportsAccessibilityAction(int)}.
244 * @param info The info to initialize.
245 * @see View#onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo)
247 public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { }
250 * Returns {@code true} if this {@link AccessibilityInjector} should handle the specified
253 * @param action An accessibility action identifier.
254 * @return {@code true} if this {@link AccessibilityInjector} should handle the specified
257 public boolean supportsAccessibilityAction(int action) {
262 * Performs the specified accessibility action.
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)
269 public boolean performAccessibilityAction(int action, Bundle arguments) {
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(),
280 mContentViewCore.addJavascriptInterface(mTextToSpeech,
281 ALIAS_ACCESSIBILITY_JS_INTERFACE);
284 if (mVibrator == null && mHasVibratePermission) {
285 mVibrator = new VibratorWrapper(context);
286 mContentViewCore.addJavascriptInterface(mVibrator,
287 ALIAS_ACCESSIBILITY_JS_INTERFACE_2);
292 protected void removeAccessibilityApis() {
293 if (mTextToSpeech != null) {
294 mContentViewCore.removeJavascriptInterface(ALIAS_ACCESSIBILITY_JS_INTERFACE);
295 mTextToSpeech.stop();
296 mTextToSpeech.shutdownInternal();
297 mTextToSpeech = null;
300 if (mVibrator != null) {
301 mContentViewCore.removeJavascriptInterface(ALIAS_ACCESSIBILITY_JS_INTERFACE_2);
307 private int getAxsUrlParameterValue() {
308 if (mContentViewCore.getWebContents().getUrl() == null) {
309 return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED;
313 List<NameValuePair> params = URLEncodedUtils.parse(
314 new URI(mContentViewCore.getWebContents().getUrl()), null);
316 for (NameValuePair param : params) {
317 if ("axs".equals(param.getName())) {
318 return Integer.parseInt(param.getValue());
321 } catch (URISyntaxException ex) {
322 // Intentional no-op.
323 } catch (NumberFormatException ex) {
324 // Intentional no-op.
325 } catch (IllegalArgumentException ex) {
326 // Intentional no-op.
329 return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED;
332 private String getScreenReaderInjectingJs() {
333 return String.format(ACCESSIBILITY_SCREEN_READER_JAVASCRIPT_TEMPLATE,
334 mAccessibilityScreenReaderUrl);
337 private AccessibilityManager getAccessibilityManager() {
338 if (mAccessibilityManager == null) {
339 mAccessibilityManager = (AccessibilityManager) mContentViewCore.getContext().
340 getSystemService(Context.ACCESSIBILITY_SERVICE);
343 return mAccessibilityManager;
347 * Used to protect how long JavaScript can vibrate for. This isn't a good comprehensive
348 * protection, just used to cover mistakes and protect against long vibrate durations/repeats.
350 * Also only exposes methods we *want* to expose, no others for the class.
352 private static class VibratorWrapper {
353 private static final long MAX_VIBRATE_DURATION_MS = 5000;
355 private final Vibrator mVibrator;
357 public VibratorWrapper(Context context) {
358 mVibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE);
362 @SuppressWarnings("unused")
363 public boolean hasVibrator() {
364 return mVibrator.hasVibrator();
368 @SuppressWarnings("unused")
369 public void vibrate(long milliseconds) {
370 milliseconds = Math.min(milliseconds, MAX_VIBRATE_DURATION_MS);
371 mVibrator.vibrate(milliseconds);
375 @SuppressWarnings("unused")
376 public void vibrate(long[] pattern, int repeat) {
377 for (int i = 0; i < pattern.length; ++i) {
378 pattern[i] = Math.min(pattern[i], MAX_VIBRATE_DURATION_MS);
383 mVibrator.vibrate(pattern, repeat);
387 @SuppressWarnings("unused")
388 public void cancel() {
394 * Used to protect the TextToSpeech class, only exposing the methods we want to expose.
396 private static class TextToSpeechWrapper {
397 private final TextToSpeech mTextToSpeech;
398 private final SelfBrailleClient mSelfBrailleClient;
399 private final View mView;
401 public TextToSpeechWrapper(View view, Context context) {
403 mTextToSpeech = new TextToSpeech(context, null, null);
404 mSelfBrailleClient = new SelfBrailleClient(context, CommandLine.getInstance().hasSwitch(
405 ContentSwitches.ACCESSIBILITY_DEBUG_BRAILLE_SERVICE));
409 @SuppressWarnings("unused")
410 public boolean isSpeaking() {
411 return mTextToSpeech.isSpeaking();
415 @SuppressWarnings("unused")
416 public int speak(String text, int queueMode, String jsonParams) {
417 // Try to pull the params from the JSON string.
418 HashMap<String, String> params = null;
420 if (jsonParams != null) {
421 params = new HashMap<String, String>();
422 JSONObject json = new JSONObject(jsonParams);
424 // Using legacy API here.
425 @SuppressWarnings("unchecked")
426 Iterator<String> keyIt = json.keys();
428 while (keyIt.hasNext()) {
429 String key = keyIt.next();
430 // Only add parameters that are raw data types.
431 if (json.optJSONObject(key) == null && json.optJSONArray(key) == null) {
432 params.put(key, json.getString(key));
436 } catch (JSONException e) {
440 return mTextToSpeech.speak(text, queueMode, params);
444 @SuppressWarnings("unused")
446 return mTextToSpeech.stop();
450 @SuppressWarnings("unused")
451 public void braille(String jsonString) {
453 JSONObject jsonObj = new JSONObject(jsonString);
455 WriteData data = WriteData.forView(mView);
456 data.setText(jsonObj.getString("text"));
457 data.setSelectionStart(jsonObj.getInt("startIndex"));
458 data.setSelectionEnd(jsonObj.getInt("endIndex"));
459 mSelfBrailleClient.write(data);
460 } catch (JSONException ex) {
461 Log.w(TAG, "Error parsing JS JSON object", ex);
465 @SuppressWarnings("unused")
466 protected void shutdownInternal() {
467 mTextToSpeech.shutdown();
468 mSelfBrailleClient.shutdown();