// 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.WebContentsObserverAndroid; 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 WebContentsObserverAndroid { 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); 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.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.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.getUrl() == null) return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED; try { List<NameValuePair> params = URLEncodedUtils.parse(new URI(mContentViewCore.getUrl()), null); for (NameValuePair param : params) { if ("axs".equals(param.getName())) { return Integer.parseInt(param.getValue()); } } } catch (URISyntaxException ex) { } catch (NumberFormatException ex) { } catch (IllegalArgumentException ex) { } 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 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 TextToSpeech mTextToSpeech; private SelfBrailleClient mSelfBrailleClient; private 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(); } } }