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