| // Copyright 2012 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| package org.chromium.content.browser.accessibility; |
| |
| import android.accessibilityservice.AccessibilityServiceInfo; |
| import android.content.Context; |
| import android.content.pm.PackageManager; |
| import android.os.Build; |
| import android.os.Bundle; |
| import android.os.Vibrator; |
| import android.speech.tts.TextToSpeech; |
| import android.util.Log; |
| import android.view.View; |
| import android.view.accessibility.AccessibilityManager; |
| import android.view.accessibility.AccessibilityNodeInfo; |
| |
| import com.googlecode.eyesfree.braille.selfbraille.SelfBrailleClient; |
| import com.googlecode.eyesfree.braille.selfbraille.WriteData; |
| |
| import org.apache.http.NameValuePair; |
| import org.apache.http.client.utils.URLEncodedUtils; |
| import org.chromium.base.CommandLine; |
| import org.chromium.content.browser.ContentViewCore; |
| import org.chromium.content.browser.JavascriptInterface; |
| import org.chromium.content.browser.WebContentsObserver; |
| import org.chromium.content.common.ContentSwitches; |
| import org.json.JSONException; |
| import org.json.JSONObject; |
| |
| import java.net.URI; |
| import java.net.URISyntaxException; |
| import java.util.HashMap; |
| import java.util.Iterator; |
| import java.util.List; |
| |
| /** |
| * Responsible for accessibility injection and management of a {@link ContentViewCore}. |
| */ |
| public class AccessibilityInjector extends WebContentsObserver { |
| private static final String TAG = "AccessibilityInjector"; |
| |
| // The ContentView this injector is responsible for managing. |
| protected ContentViewCore mContentViewCore; |
| |
| // The Java objects that are exposed to JavaScript |
| private TextToSpeechWrapper mTextToSpeech; |
| private VibratorWrapper mVibrator; |
| private final boolean mHasVibratePermission; |
| |
| // Lazily loaded helper objects. |
| private AccessibilityManager mAccessibilityManager; |
| |
| // Whether or not we should be injecting the script. |
| protected boolean mInjectedScriptEnabled; |
| protected boolean mScriptInjected; |
| |
| private final String mAccessibilityScreenReaderUrl; |
| |
| // To support building against the JELLY_BEAN and not JELLY_BEAN_MR1 SDK we need to add this |
| // constant here. |
| private static final int FEEDBACK_BRAILLE = 0x00000020; |
| |
| // constants for determining script injection strategy |
| private static final int ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED = -1; |
| private static final int ACCESSIBILITY_SCRIPT_INJECTION_OPTED_OUT = 0; |
| private static final int ACCESSIBILITY_SCRIPT_INJECTION_PROVIDED = 1; |
| private static final String ALIAS_ACCESSIBILITY_JS_INTERFACE = "accessibility"; |
| private static final String ALIAS_ACCESSIBILITY_JS_INTERFACE_2 = "accessibility2"; |
| |
| // Template for JavaScript that injects a screen-reader. |
| private static final String DEFAULT_ACCESSIBILITY_SCREEN_READER_URL = |
| "https://ssl.gstatic.com/accessibility/javascript/android/chromeandroidvox.js"; |
| |
| private static final String ACCESSIBILITY_SCREEN_READER_JAVASCRIPT_TEMPLATE = |
| "(function() {" |
| + " var chooser = document.createElement('script');" |
| + " chooser.type = 'text/javascript';" |
| + " chooser.src = '%1s';" |
| + " document.getElementsByTagName('head')[0].appendChild(chooser);" |
| + " })();"; |
| |
| // JavaScript call to turn ChromeVox on or off. |
| private static final String TOGGLE_CHROME_VOX_JAVASCRIPT = |
| "(function() {" |
| + " if (typeof cvox !== 'undefined') {" |
| + " cvox.ChromeVox.host.activateOrDeactivateChromeVox(%1s);" |
| + " }" |
| + " })();"; |
| |
| /** |
| * Returns an instance of the {@link AccessibilityInjector} based on the SDK version. |
| * @param view The ContentViewCore that this AccessibilityInjector manages. |
| * @return An instance of a {@link AccessibilityInjector}. |
| */ |
| public static AccessibilityInjector newInstance(ContentViewCore view) { |
| if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) { |
| return new AccessibilityInjector(view); |
| } else { |
| return new JellyBeanAccessibilityInjector(view); |
| } |
| } |
| |
| /** |
| * Creates an instance of the IceCreamSandwichAccessibilityInjector. |
| * @param view The ContentViewCore that this AccessibilityInjector manages. |
| */ |
| protected AccessibilityInjector(ContentViewCore view) { |
| super(view.getWebContents()); |
| mContentViewCore = view; |
| |
| mAccessibilityScreenReaderUrl = CommandLine.getInstance().getSwitchValue( |
| ContentSwitches.ACCESSIBILITY_JAVASCRIPT_URL, |
| DEFAULT_ACCESSIBILITY_SCREEN_READER_URL); |
| |
| mHasVibratePermission = mContentViewCore.getContext().checkCallingOrSelfPermission( |
| android.Manifest.permission.VIBRATE) == PackageManager.PERMISSION_GRANTED; |
| } |
| |
| /** |
| * Injects a <script> tag into the current web site that pulls in the ChromeVox script for |
| * accessibility support. Only injects if accessibility is turned on by |
| * {@link AccessibilityManager#isEnabled()}, accessibility script injection is turned on, and |
| * javascript is enabled on this page. |
| * |
| * @see AccessibilityManager#isEnabled() |
| */ |
| public void injectAccessibilityScriptIntoPage() { |
| if (!accessibilityIsAvailable()) return; |
| |
| int axsParameterValue = getAxsUrlParameterValue(); |
| if (axsParameterValue != ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED) { |
| return; |
| } |
| |
| String js = getScreenReaderInjectingJs(); |
| if (mContentViewCore.isDeviceAccessibilityScriptInjectionEnabled() |
| && js != null && mContentViewCore.isAlive()) { |
| addOrRemoveAccessibilityApisIfNecessary(); |
| mContentViewCore.getWebContents().evaluateJavaScript(js, null); |
| mInjectedScriptEnabled = true; |
| mScriptInjected = true; |
| } |
| } |
| |
| /** |
| * Handles adding or removing accessibility related Java objects ({@link TextToSpeech} and |
| * {@link Vibrator}) interfaces from Javascript. This method should be called at a time when it |
| * is safe to add or remove these interfaces, specifically when the {@link ContentViewCore} is |
| * first initialized or right before the {@link ContentViewCore} is about to navigate to a URL |
| * or reload. |
| * <p> |
| * If this method is called at other times, the interfaces might not be correctly removed, |
| * meaning that Javascript can still access these Java objects that may have been already |
| * shut down. |
| */ |
| public void addOrRemoveAccessibilityApisIfNecessary() { |
| if (accessibilityIsAvailable()) { |
| addAccessibilityApis(); |
| } else { |
| removeAccessibilityApis(); |
| } |
| } |
| |
| /** |
| * Checks whether or not touch to explore is enabled on the system. |
| */ |
| public boolean accessibilityIsAvailable() { |
| if (!getAccessibilityManager().isEnabled() |
| || mContentViewCore.getContentSettings() == null |
| || !mContentViewCore.getContentSettings().getJavaScriptEnabled()) { |
| return false; |
| } |
| |
| try { |
| // Check that there is actually a service running that requires injecting this script. |
| List<AccessibilityServiceInfo> services = |
| getAccessibilityManager().getEnabledAccessibilityServiceList( |
| FEEDBACK_BRAILLE | AccessibilityServiceInfo.FEEDBACK_SPOKEN); |
| return services.size() > 0; |
| } catch (NullPointerException e) { |
| // getEnabledAccessibilityServiceList() can throw an NPE due to a bad |
| // AccessibilityService. |
| return false; |
| } |
| } |
| |
| /** |
| * Sets whether or not the script is enabled. If the script is disabled, we also stop any |
| * we output that is occurring. If the script has not yet been injected, injects it. |
| * @param enabled Whether or not to enable the script. |
| */ |
| public void setScriptEnabled(boolean enabled) { |
| if (enabled && !mScriptInjected) injectAccessibilityScriptIntoPage(); |
| if (!accessibilityIsAvailable() || mInjectedScriptEnabled == enabled) return; |
| |
| mInjectedScriptEnabled = enabled; |
| if (mContentViewCore.isAlive()) { |
| String js = String.format(TOGGLE_CHROME_VOX_JAVASCRIPT, Boolean.toString( |
| mInjectedScriptEnabled)); |
| mContentViewCore.getWebContents().evaluateJavaScript(js, null); |
| |
| if (!mInjectedScriptEnabled) { |
| // Stop any TTS/Vibration right now. |
| onPageLostFocus(); |
| } |
| } |
| } |
| |
| /** |
| * Notifies this handler that a page load has started, which means we should mark the |
| * accessibility script as not being injected. This way we can properly ignore incoming |
| * accessibility gesture events. |
| */ |
| @Override |
| public void didStartLoading(String url) { |
| mScriptInjected = false; |
| } |
| |
| @Override |
| public void didStopLoading(String url) { |
| injectAccessibilityScriptIntoPage(); |
| } |
| |
| /** |
| * Stop any notifications that are currently going on (e.g. Text-to-Speech). |
| */ |
| public void onPageLostFocus() { |
| if (mContentViewCore.isAlive()) { |
| if (mTextToSpeech != null) mTextToSpeech.stop(); |
| if (mVibrator != null) mVibrator.cancel(); |
| } |
| } |
| |
| /** |
| * Initializes an {@link AccessibilityNodeInfo} with the actions and movement granularity |
| * levels supported by this {@link AccessibilityInjector}. |
| * <p> |
| * If an action identifier is added in this method, this {@link AccessibilityInjector} should |
| * also return {@code true} from {@link #supportsAccessibilityAction(int)}. |
| * </p> |
| * |
| * @param info The info to initialize. |
| * @see View#onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo) |
| */ |
| public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { } |
| |
| /** |
| * Returns {@code true} if this {@link AccessibilityInjector} should handle the specified |
| * action. |
| * |
| * @param action An accessibility action identifier. |
| * @return {@code true} if this {@link AccessibilityInjector} should handle the specified |
| * action. |
| */ |
| public boolean supportsAccessibilityAction(int action) { |
| return false; |
| } |
| |
| /** |
| * Performs the specified accessibility action. |
| * |
| * @param action The identifier of the action to perform. |
| * @param arguments The action arguments, or {@code null} if no arguments. |
| * @return {@code true} if the action was successful. |
| * @see View#performAccessibilityAction(int, Bundle) |
| */ |
| public boolean performAccessibilityAction(int action, Bundle arguments) { |
| return false; |
| } |
| |
| protected void addAccessibilityApis() { |
| Context context = mContentViewCore.getContext(); |
| if (context != null) { |
| // Enabled, we should try to add if we have to. |
| if (mTextToSpeech == null) { |
| mTextToSpeech = new TextToSpeechWrapper(mContentViewCore.getContainerView(), |
| context); |
| mContentViewCore.addJavascriptInterface(mTextToSpeech, |
| ALIAS_ACCESSIBILITY_JS_INTERFACE); |
| } |
| |
| if (mVibrator == null && mHasVibratePermission) { |
| mVibrator = new VibratorWrapper(context); |
| mContentViewCore.addJavascriptInterface(mVibrator, |
| ALIAS_ACCESSIBILITY_JS_INTERFACE_2); |
| } |
| } |
| } |
| |
| protected void removeAccessibilityApis() { |
| if (mTextToSpeech != null) { |
| mContentViewCore.removeJavascriptInterface(ALIAS_ACCESSIBILITY_JS_INTERFACE); |
| mTextToSpeech.stop(); |
| mTextToSpeech.shutdownInternal(); |
| mTextToSpeech = null; |
| } |
| |
| if (mVibrator != null) { |
| mContentViewCore.removeJavascriptInterface(ALIAS_ACCESSIBILITY_JS_INTERFACE_2); |
| mVibrator.cancel(); |
| mVibrator = null; |
| } |
| } |
| |
| private int getAxsUrlParameterValue() { |
| if (mContentViewCore.getWebContents().getUrl() == null) { |
| return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED; |
| } |
| |
| try { |
| List<NameValuePair> params = URLEncodedUtils.parse( |
| new URI(mContentViewCore.getWebContents().getUrl()), null); |
| |
| for (NameValuePair param : params) { |
| if ("axs".equals(param.getName())) { |
| return Integer.parseInt(param.getValue()); |
| } |
| } |
| } catch (URISyntaxException ex) { |
| // Intentional no-op. |
| } catch (NumberFormatException ex) { |
| // Intentional no-op. |
| } catch (IllegalArgumentException ex) { |
| // Intentional no-op. |
| } |
| |
| return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED; |
| } |
| |
| private String getScreenReaderInjectingJs() { |
| return String.format(ACCESSIBILITY_SCREEN_READER_JAVASCRIPT_TEMPLATE, |
| mAccessibilityScreenReaderUrl); |
| } |
| |
| private AccessibilityManager getAccessibilityManager() { |
| if (mAccessibilityManager == null) { |
| mAccessibilityManager = (AccessibilityManager) mContentViewCore.getContext() |
| .getSystemService(Context.ACCESSIBILITY_SERVICE); |
| } |
| |
| return mAccessibilityManager; |
| } |
| |
| /** |
| * Used to protect how long JavaScript can vibrate for. This isn't a good comprehensive |
| * protection, just used to cover mistakes and protect against long vibrate durations/repeats. |
| * |
| * Also only exposes methods we *want* to expose, no others for the class. |
| */ |
| private static class VibratorWrapper { |
| private static final long MAX_VIBRATE_DURATION_MS = 5000; |
| |
| private final Vibrator mVibrator; |
| |
| public VibratorWrapper(Context context) { |
| mVibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); |
| } |
| |
| @JavascriptInterface |
| @SuppressWarnings("unused") |
| public boolean hasVibrator() { |
| return mVibrator.hasVibrator(); |
| } |
| |
| @JavascriptInterface |
| @SuppressWarnings("unused") |
| public void vibrate(long milliseconds) { |
| milliseconds = Math.min(milliseconds, MAX_VIBRATE_DURATION_MS); |
| mVibrator.vibrate(milliseconds); |
| } |
| |
| @JavascriptInterface |
| @SuppressWarnings("unused") |
| public void vibrate(long[] pattern, int repeat) { |
| for (int i = 0; i < pattern.length; ++i) { |
| pattern[i] = Math.min(pattern[i], MAX_VIBRATE_DURATION_MS); |
| } |
| |
| repeat = -1; |
| |
| mVibrator.vibrate(pattern, repeat); |
| } |
| |
| @JavascriptInterface |
| @SuppressWarnings("unused") |
| public void cancel() { |
| mVibrator.cancel(); |
| } |
| } |
| |
| /** |
| * Used to protect the TextToSpeech class, only exposing the methods we want to expose. |
| */ |
| private static class TextToSpeechWrapper { |
| private final TextToSpeech mTextToSpeech; |
| private final SelfBrailleClient mSelfBrailleClient; |
| private final View mView; |
| |
| public TextToSpeechWrapper(View view, Context context) { |
| mView = view; |
| mTextToSpeech = new TextToSpeech(context, null, null); |
| mSelfBrailleClient = new SelfBrailleClient(context, CommandLine.getInstance().hasSwitch( |
| ContentSwitches.ACCESSIBILITY_DEBUG_BRAILLE_SERVICE)); |
| } |
| |
| @JavascriptInterface |
| @SuppressWarnings("unused") |
| public boolean isSpeaking() { |
| return mTextToSpeech.isSpeaking(); |
| } |
| |
| @JavascriptInterface |
| @SuppressWarnings("unused") |
| public int speak(String text, int queueMode, String jsonParams) { |
| // Try to pull the params from the JSON string. |
| HashMap<String, String> params = null; |
| try { |
| if (jsonParams != null) { |
| params = new HashMap<String, String>(); |
| JSONObject json = new JSONObject(jsonParams); |
| |
| // Using legacy API here. |
| @SuppressWarnings("unchecked") |
| Iterator<String> keyIt = json.keys(); |
| |
| while (keyIt.hasNext()) { |
| String key = keyIt.next(); |
| // Only add parameters that are raw data types. |
| if (json.optJSONObject(key) == null && json.optJSONArray(key) == null) { |
| params.put(key, json.getString(key)); |
| } |
| } |
| } |
| } catch (JSONException e) { |
| params = null; |
| } |
| |
| return mTextToSpeech.speak(text, queueMode, params); |
| } |
| |
| @JavascriptInterface |
| @SuppressWarnings("unused") |
| public int stop() { |
| return mTextToSpeech.stop(); |
| } |
| |
| @JavascriptInterface |
| @SuppressWarnings("unused") |
| public void braille(String jsonString) { |
| try { |
| JSONObject jsonObj = new JSONObject(jsonString); |
| |
| WriteData data = WriteData.forView(mView); |
| data.setText(jsonObj.getString("text")); |
| data.setSelectionStart(jsonObj.getInt("startIndex")); |
| data.setSelectionEnd(jsonObj.getInt("endIndex")); |
| mSelfBrailleClient.write(data); |
| } catch (JSONException ex) { |
| Log.w(TAG, "Error parsing JS JSON object", ex); |
| } |
| } |
| |
| @SuppressWarnings("unused") |
| protected void shutdownInternal() { |
| mTextToSpeech.shutdown(); |
| mSelfBrailleClient.shutdown(); |
| } |
| } |
| } |