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