/* * Copyright (C) 2012 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package android.webkit; import android.content.Context; import android.os.Bundle; import android.os.Handler; import android.os.SystemClock; import android.provider.Settings; import android.speech.tts.TextToSpeech; import android.speech.tts.TextToSpeech.Engine; import android.speech.tts.TextToSpeech.OnInitListener; import android.speech.tts.UtteranceProgressListener; import android.util.Log; import android.view.KeyEvent; import android.view.View; import android.view.accessibility.AccessibilityManager; import android.view.accessibility.AccessibilityNodeInfo; import android.webkit.WebViewCore.EventHub; import org.apache.http.NameValuePair; import org.apache.http.client.utils.URLEncodedUtils; 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; import java.util.concurrent.atomic.AtomicInteger; /** * Handles injecting accessibility JavaScript and related JavaScript -> Java * APIs. */ class AccessibilityInjector { private static final String TAG = AccessibilityInjector.class.getSimpleName(); private static boolean DEBUG = false; // The WebViewClassic this injector is responsible for managing. private final WebViewClassic mWebViewClassic; // Cached reference to mWebViewClassic.getContext(), for convenience. private final Context mContext; // Cached reference to mWebViewClassic.getWebView(), for convenience. private final WebView mWebView; // The Java objects that are exposed to JavaScript. private TextToSpeechWrapper mTextToSpeech; private CallbackHandler mCallback; // Lazily loaded helper objects. private AccessibilityManager mAccessibilityManager; private AccessibilityInjectorFallback mAccessibilityInjectorFallback; private JSONObject mAccessibilityJSONObject; // Whether the accessibility script has been injected into the current page. private boolean mAccessibilityScriptInjected; // 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; @SuppressWarnings("unused") private static final int ACCESSIBILITY_SCRIPT_INJECTION_PROVIDED = 1; // Alias for TTS API exposed to JavaScript. private static final String ALIAS_TTS_JS_INTERFACE = "accessibility"; // Alias for traversal callback exposed to JavaScript. private static final String ALIAS_TRAVERSAL_JS_INTERFACE = "accessibilityTraversal"; // Template for JavaScript that injects a screen-reader. private static final String ACCESSIBILITY_SCREEN_READER_JAVASCRIPT_TEMPLATE = "javascript:(function() {" + " var chooser = document.createElement('script');" + " chooser.type = 'text/javascript';" + " chooser.src = '%1s';" + " document.getElementsByTagName('head')[0].appendChild(chooser);" + " })();"; // Template for JavaScript that performs AndroidVox actions. private static final String ACCESSIBILITY_ANDROIDVOX_TEMPLATE = "(function() {" + " if ((typeof(cvox) != 'undefined')" + " && (cvox != null)" + " && (typeof(cvox.ChromeVox) != 'undefined')" + " && (cvox.ChromeVox != null)" + " && (typeof(cvox.AndroidVox) != 'undefined')" + " && (cvox.AndroidVox != null)" + " && cvox.ChromeVox.isActive) {" + " return cvox.AndroidVox.performAction('%1s');" + " } else {" + " return false;" + " }" + "})()"; // JS code used to shut down an active AndroidVox instance. private static final String TOGGLE_CVOX_TEMPLATE = "javascript:(function() {" + " if ((typeof(cvox) != 'undefined')" + " && (cvox != null)" + " && (typeof(cvox.ChromeVox) != 'undefined')" + " && (cvox.ChromeVox != null)" + " && (typeof(cvox.ChromeVox.host) != 'undefined')" + " && (cvox.ChromeVox.host != null)) {" + " cvox.ChromeVox.host.activateOrDeactivateChromeVox(%b);" + " }" + "})();"; /** * Creates an instance of the AccessibilityInjector based on * {@code webViewClassic}. * * @param webViewClassic The WebViewClassic that this AccessibilityInjector * manages. */ public AccessibilityInjector(WebViewClassic webViewClassic) { mWebViewClassic = webViewClassic; mWebView = webViewClassic.getWebView(); mContext = webViewClassic.getContext(); mAccessibilityManager = AccessibilityManager.getInstance(mContext); } /** * If JavaScript is enabled, pauses or resumes AndroidVox. * * @param enabled Whether feedback should be enabled. */ public void toggleAccessibilityFeedback(boolean enabled) { if (!isAccessibilityEnabled() || !isJavaScriptEnabled()) { return; } toggleAndroidVox(enabled); if (!enabled && (mTextToSpeech != null)) { mTextToSpeech.stop(); } } /** * Attempts to load scripting interfaces for accessibility. *

* This should only be called before a page loads. */ public void addAccessibilityApisIfNecessary() { if (!isAccessibilityEnabled() || !isJavaScriptEnabled()) { return; } addTtsApis(); addCallbackApis(); } /** * Attempts to unload scripting interfaces for accessibility. *

* This should only be called before a page loads. */ private void removeAccessibilityApisIfNecessary() { removeTtsApis(); removeCallbackApis(); } /** * Destroys this accessibility injector. */ public void destroy() { if (mTextToSpeech != null) { mTextToSpeech.shutdown(); mTextToSpeech = null; } if (mCallback != null) { mCallback = null; } } private void toggleAndroidVox(boolean state) { if (!mAccessibilityScriptInjected) { return; } final String code = String.format(TOGGLE_CVOX_TEMPLATE, state); mWebView.loadUrl(code); } /** * Initializes an {@link AccessibilityNodeInfo} with the actions and * movement granularity levels supported by this * {@link AccessibilityInjector}. *

* If an action identifier is added in this method, this * {@link AccessibilityInjector} should also return {@code true} from * {@link #supportsAccessibilityAction(int)}. *

* * @param info The info to initialize. * @see View#onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo) */ public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { info.setMovementGranularities(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PAGE); info.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY); info.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY); info.addAction(AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT); info.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT); info.addAction(AccessibilityNodeInfo.ACTION_CLICK); info.setClickable(true); } /** * 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) { switch (action) { case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY: case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY: case AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT: case AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT: case AccessibilityNodeInfo.ACTION_CLICK: return true; default: 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) { if (!isAccessibilityEnabled()) { mAccessibilityScriptInjected = false; toggleFallbackAccessibilityInjector(false); return false; } if (mAccessibilityScriptInjected) { return sendActionToAndroidVox(action, arguments); } if (mAccessibilityInjectorFallback != null) { return mAccessibilityInjectorFallback.performAccessibilityAction(action, arguments); } return false; } /** * Attempts to handle key events when accessibility is turned on. * * @param event The key event to handle. * @return {@code true} if the event was handled. */ public boolean handleKeyEventIfNecessary(KeyEvent event) { if (!isAccessibilityEnabled()) { mAccessibilityScriptInjected = false; toggleFallbackAccessibilityInjector(false); return false; } if (mAccessibilityScriptInjected) { // if an accessibility script is injected we delegate to it the key // handling. this script is a screen reader which is a fully fledged // solution for blind users to navigate in and interact with web // pages. if (event.getAction() == KeyEvent.ACTION_UP) { mWebViewClassic.sendBatchableInputMessage(EventHub.KEY_UP, 0, 0, event); } else if (event.getAction() == KeyEvent.ACTION_DOWN) { mWebViewClassic.sendBatchableInputMessage(EventHub.KEY_DOWN, 0, 0, event); } else { return false; } return true; } if (mAccessibilityInjectorFallback != null) { // if an accessibility injector is present (no JavaScript enabled or // the site opts out injecting our JavaScript screen reader) we let // it decide whether to act on and consume the event. return mAccessibilityInjectorFallback.onKeyEvent(event); } return false; } /** * Attempts to handle selection change events when accessibility is using a * non-JavaScript method. * * @param selectionString The selection string. */ public void handleSelectionChangedIfNecessary(String selectionString) { if (mAccessibilityInjectorFallback != null) { mAccessibilityInjectorFallback.onSelectionStringChange(selectionString); } } /** * Prepares for injecting accessibility scripts into a new page. * * @param url The URL that will be loaded. */ public void onPageStarted(String url) { mAccessibilityScriptInjected = false; if (DEBUG) { Log.w(TAG, "[" + mWebView.hashCode() + "] Started loading new page"); } addAccessibilityApisIfNecessary(); } /** * Attempts to inject the accessibility script using a {@code