• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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