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 | |
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.provider.Settings; |
14 | import android.speech.tts.TextToSpeech; |
15 | import android.util.Log; |
16 | import android.view.View; |
17 | import android.view.accessibility.AccessibilityManager; |
18 | import android.view.accessibility.AccessibilityNodeInfo; |
19 | |
20 | import com.googlecode.eyesfree.braille.selfbraille.SelfBrailleClient; |
21 | import com.googlecode.eyesfree.braille.selfbraille.WriteData; |
22 | |
23 | import org.apache.http.NameValuePair; |
24 | import org.apache.http.client.utils.URLEncodedUtils; |
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.CommandLine; |
29 | import org.json.JSONException; |
30 | import org.json.JSONObject; |
31 | |
32 | import java.lang.reflect.Field; |
33 | import java.net.URI; |
34 | import java.net.URISyntaxException; |
35 | import java.util.HashMap; |
36 | import java.util.Iterator; |
37 | import java.util.List; |
38 | |
39 | /** |
40 | * Responsible for accessibility injection and management of a {@link ContentViewCore}. |
41 | */ |
42 | public class AccessibilityInjector extends WebContentsObserverAndroid { |
43 | private static final String TAG = "AccessibilityInjector"; |
44 | |
45 | // The ContentView this injector is responsible for managing. |
46 | protected ContentViewCore mContentViewCore; |
47 | |
48 | // The Java objects that are exposed to JavaScript |
49 | private TextToSpeechWrapper mTextToSpeech; |
50 | private VibratorWrapper mVibrator; |
51 | private final boolean mHasVibratePermission; |
52 | |
53 | // Lazily loaded helper objects. |
54 | private AccessibilityManager mAccessibilityManager; |
55 | |
56 | // Whether or not we should be injecting the script. |
57 | protected boolean mInjectedScriptEnabled; |
58 | protected boolean mScriptInjected; |
59 | |
60 | private final String mAccessibilityScreenReaderUrl; |
61 | |
62 | // To support building against the JELLY_BEAN and not JELLY_BEAN_MR1 SDK we need to add this |
63 | // constant here. |
64 | private static final int FEEDBACK_BRAILLE = 0x00000020; |
65 | |
66 | // constants for determining script injection strategy |
67 | private static final int ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED = -1; |
68 | private static final int ACCESSIBILITY_SCRIPT_INJECTION_OPTED_OUT = 0; |
69 | private static final int ACCESSIBILITY_SCRIPT_INJECTION_PROVIDED = 1; |
70 | private static final String ALIAS_ACCESSIBILITY_JS_INTERFACE = "accessibility"; |
71 | private static final String ALIAS_ACCESSIBILITY_JS_INTERFACE_2 = "accessibility2"; |
72 | |
73 | // Template for JavaScript that injects a screen-reader. |
74 | private static final String DEFAULT_ACCESSIBILITY_SCREEN_READER_URL = |
75 | "https://ssl.gstatic.com/accessibility/javascript/android/chromeandroidvox.js"; |
76 | |
77 | private static final String ACCESSIBILITY_SCREEN_READER_JAVASCRIPT_TEMPLATE = |
78 | "(function() {" + |
79 | " var chooser = document.createElement('script');" + |
80 | " chooser.type = 'text/javascript';" + |
81 | " chooser.src = '%1s';" + |
82 | " document.getElementsByTagName('head')[0].appendChild(chooser);" + |
83 | " })();"; |
84 | |
85 | // JavaScript call to turn ChromeVox on or off. |
86 | private static final String TOGGLE_CHROME_VOX_JAVASCRIPT = |
87 | "(function() {" + |
88 | " if (typeof cvox !== 'undefined') {" + |
89 | " cvox.ChromeVox.host.activateOrDeactivateChromeVox(%1s);" + |
90 | " }" + |
91 | " })();"; |
92 | |
93 | /** |
94 | * Returns an instance of the {@link AccessibilityInjector} based on the SDK version. |
95 | * @param view The ContentViewCore that this AccessibilityInjector manages. |
96 | * @return An instance of a {@link AccessibilityInjector}. |
97 | */ |
98 | public static AccessibilityInjector newInstance(ContentViewCore view) { |
99 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) { |
100 | return new AccessibilityInjector(view); |
101 | } else { |
102 | return new JellyBeanAccessibilityInjector(view); |
103 | } |
104 | } |
105 | |
106 | /** |
107 | * Creates an instance of the IceCreamSandwichAccessibilityInjector. |
108 | * @param view The ContentViewCore that this AccessibilityInjector manages. |
109 | */ |
110 | protected AccessibilityInjector(ContentViewCore view) { |
111 | super(view); |
112 | mContentViewCore = view; |
113 | |
114 | mAccessibilityScreenReaderUrl = CommandLine.getInstance().getSwitchValue( |
115 | CommandLine.ACCESSIBILITY_JAVASCRIPT_URL, 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 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 TextToSpeech mTextToSpeech; |
393 | private SelfBrailleClient mSelfBrailleClient; |
394 | private 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 | CommandLine.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 | } |