AccessibilityInjector.java revision 116680a4aac90f2aa7413d9095a592090648e557
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
5package org.chromium.content.browser.accessibility;
6
7import android.accessibilityservice.AccessibilityServiceInfo;
8import android.content.Context;
9import android.content.pm.PackageManager;
10import android.os.Build;
11import android.os.Bundle;
12import android.os.Vibrator;
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.base.CommandLine;
25import org.chromium.content.browser.ContentViewCore;
26import org.chromium.content.browser.JavascriptInterface;
27import org.chromium.content.browser.WebContentsObserverAndroid;
28import org.chromium.content.common.ContentSwitches;
29import org.json.JSONException;
30import org.json.JSONObject;
31
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";
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     */
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     */
109    protected AccessibilityInjector(ContentViewCore view) {
110        super(view.getWebContents());
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     */
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 final 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 final TextToSpeech mTextToSpeech;
393        private final SelfBrailleClient mSelfBrailleClient;
394        private final 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                    ContentSwitches.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}
467