/*
* 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.SystemClock;
import android.provider.Settings;
import android.speech.tts.TextToSpeech;
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.Iterator;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Handles injecting accessibility JavaScript and related JavaScript -> Java
* APIs.
*/
class AccessibilityInjector {
// 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 TextToSpeech 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 =
"cvox.AndroidVox.performAction('%1s')";
/**
* 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);
}
/**
* Attempts to load scripting interfaces for accessibility.
* <p>
* This should be called when the window is attached.
* </p>
*/
public void addAccessibilityApisIfNecessary() {
if (!isAccessibilityEnabled() || !isJavaScriptEnabled()) {
return;
}
addTtsApis();
addCallbackApis();
}
/**
* Attempts to unload scripting interfaces for accessibility.
* <p>
* This should be called when the window is detached.
* </p>
*/
public void removeAccessibilityApisIfNecessary() {
removeTtsApis();
removeCallbackApis();
}
/**
* 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) {
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;
}
/**
* Attempts to inject the accessibility script using a {@code <script>} tag.
* <p>
* This should be called after a page has finished loading.
* </p>
*
* @param url The URL that just finished loading.
*/
public void onPageFinished(String url) {
if (!isAccessibilityEnabled()) {
mAccessibilityScriptInjected = false;
toggleFallbackAccessibilityInjector(false);
return;
}
if (!shouldInjectJavaScript(url)) {
toggleFallbackAccessibilityInjector(true);
return;
}
toggleFallbackAccessibilityInjector(false);
final String injectionUrl = getScreenReaderInjectionUrl();
mWebView.loadUrl(injectionUrl);
mAccessibilityScriptInjected = true;
}
/**
* Toggles the non-JavaScript method for handling accessibility.
*
* @param enabled {@code true} to enable the non-JavaScript method, or
* {@code false} to disable it.
*/
private void toggleFallbackAccessibilityInjector(boolean enabled) {
if (enabled && (mAccessibilityInjectorFallback == null)) {
mAccessibilityInjectorFallback = new AccessibilityInjectorFallback(mWebViewClassic);
} else {
mAccessibilityInjectorFallback = null;
}
}
/**
* Determines whether it's okay to inject JavaScript into a given URL.
*
* @param url The URL to check.
* @return {@code true} if JavaScript should be injected, {@code false} if a
* non-JavaScript method should be used.
*/
private boolean shouldInjectJavaScript(String url) {
// Respect the WebView's JavaScript setting.
if (!isJavaScriptEnabled()) {
return false;
}
// Allow the page to opt out of Accessibility script injection.
if (getAxsUrlParameterValue(url) == ACCESSIBILITY_SCRIPT_INJECTION_OPTED_OUT) {
return false;
}
// The user must explicitly enable Accessibility script injection.
if (!isScriptInjectionEnabled()) {
return false;
}
return true;
}
/**
* @return {@code true} if the user has explicitly enabled Accessibility
* script injection.
*/
private boolean isScriptInjectionEnabled() {
final int injectionSetting = Settings.Secure.getInt(
mContext.getContentResolver(), Settings.Secure.ACCESSIBILITY_SCRIPT_INJECTION, 0);
return (injectionSetting == 1);
}
/**
* Attempts to initialize and add interfaces for TTS, if that hasn't already
* been done.
*/
private void addTtsApis() {
if (mTextToSpeech != null) {
return;
}
final String pkgName = mContext.getPackageName();
mTextToSpeech = new TextToSpeech(mContext, null, null, pkgName + ".**webview**", true);
mWebView.addJavascriptInterface(mTextToSpeech, ALIAS_TTS_JS_INTERFACE);
}
/**
* Attempts to shutdown and remove interfaces for TTS, if that hasn't
* already been done.
*/
private void removeTtsApis() {
if (mTextToSpeech == null) {
return;
}
mWebView.removeJavascriptInterface(ALIAS_TTS_JS_INTERFACE);
mTextToSpeech.stop();
mTextToSpeech.shutdown();
mTextToSpeech = null;
}
private void addCallbackApis() {
if (mCallback != null) {
return;
}
mCallback = new CallbackHandler(ALIAS_TRAVERSAL_JS_INTERFACE);
mWebView.addJavascriptInterface(mCallback, ALIAS_TRAVERSAL_JS_INTERFACE);
}
private void removeCallbackApis() {
if (mCallback == null) {
return;
}
mWebView.removeJavascriptInterface(ALIAS_TRAVERSAL_JS_INTERFACE);
mCallback = null;
}
/**
* Returns the script injection preference requested by the URL, or
* {@link #ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED} if the page has no
* preference.
*
* @param url The URL to check.
* @return A script injection preference.
*/
private int getAxsUrlParameterValue(String url) {
if (url == null) {
return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED;
}
try {
final List<NameValuePair> params = URLEncodedUtils.parse(new URI(url), null);
for (NameValuePair param : params) {
if ("axs".equals(param.getName())) {
return verifyInjectionValue(param.getValue());
}
}
} catch (URISyntaxException e) {
// Do nothing.
}
return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED;
}
private int verifyInjectionValue(String value) {
try {
final int parsed = Integer.parseInt(value);
switch (parsed) {
case ACCESSIBILITY_SCRIPT_INJECTION_OPTED_OUT:
return ACCESSIBILITY_SCRIPT_INJECTION_OPTED_OUT;
case ACCESSIBILITY_SCRIPT_INJECTION_PROVIDED:
return ACCESSIBILITY_SCRIPT_INJECTION_PROVIDED;
}
} catch (NumberFormatException e) {
// Do nothing.
}
return ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED;
}
/**
* @return The URL for injecting the screen reader.
*/
private String getScreenReaderInjectionUrl() {
final String screenReaderUrl = Settings.Secure.getString(
mContext.getContentResolver(), Settings.Secure.ACCESSIBILITY_SCREEN_READER_URL);
return String.format(ACCESSIBILITY_SCREEN_READER_JAVASCRIPT_TEMPLATE, screenReaderUrl);
}
/**
* @return {@code true} if JavaScript is enabled in the {@link WebView}
* settings.
*/
private boolean isJavaScriptEnabled() {
return mWebView.getSettings().getJavaScriptEnabled();
}
/**
* @return {@code true} if accessibility is enabled.
*/
private boolean isAccessibilityEnabled() {
return mAccessibilityManager.isEnabled();
}
/**
* Packs an accessibility action into a JSON object and sends it to AndroidVox.
*
* @param action The action identifier.
* @param arguments The action arguments, if applicable.
* @return The result of the action.
*/
private boolean sendActionToAndroidVox(int action, Bundle arguments) {
if (mAccessibilityJSONObject == null) {
mAccessibilityJSONObject = new JSONObject();
} else {
// Remove all keys from the object.
final Iterator<?> keys = mAccessibilityJSONObject.keys();
while (keys.hasNext()) {
keys.next();
keys.remove();
}
}
try {
mAccessibilityJSONObject.accumulate("action", action);
switch (action) {
case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY:
case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY:
if (arguments != null) {
final int granularity = arguments.getInt(
AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT);
mAccessibilityJSONObject.accumulate("granularity", granularity);
}
break;
case AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT:
case AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT:
if (arguments != null) {
final String element = arguments.getString(
AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING);
mAccessibilityJSONObject.accumulate("element", element);
}
break;
}
} catch (JSONException e) {
return false;
}
final String jsonString = mAccessibilityJSONObject.toString();
final String jsCode = String.format(ACCESSIBILITY_ANDROIDVOX_TEMPLATE, jsonString);
return mCallback.performAction(mWebView, jsCode);
}
/**
* Exposes result interface to JavaScript.
*/
private static class CallbackHandler {
private static final String JAVASCRIPT_ACTION_TEMPLATE =
"javascript:(function() { %s.onResult(%d, %s); })();";
// Time in milliseconds to wait for a result before failing.
private static final long RESULT_TIMEOUT = 5000;
private final AtomicInteger mResultIdCounter = new AtomicInteger();
private final Object mResultLock = new Object();
private final String mInterfaceName;
private boolean mResult = false;
private long mResultId = -1;
private CallbackHandler(String interfaceName) {
mInterfaceName = interfaceName;
}
/**
* Performs an action and attempts to wait for a result.
*
* @param webView The WebView to perform the action on.
* @param code JavaScript code that evaluates to a result.
* @return The result of the action, or false if it timed out.
*/
private boolean performAction(WebView webView, String code) {
final int resultId = mResultIdCounter.getAndIncrement();
final String url = String.format(
JAVASCRIPT_ACTION_TEMPLATE, mInterfaceName, resultId, code);
webView.loadUrl(url);
return getResultAndClear(resultId);
}
/**
* Gets the result of a request to perform an accessibility action.
*
* @param resultId The result id to match the result with the request.
* @return The result of the request.
*/
private boolean getResultAndClear(int resultId) {
synchronized (mResultLock) {
final boolean success = waitForResultTimedLocked(resultId);
final boolean result = success ? mResult : false;
clearResultLocked();
return result;
}
}
/**
* Clears the result state.
*/
private void clearResultLocked() {
mResultId = -1;
mResult = false;
}
/**
* Waits up to a given bound for a result of a request and returns it.
*
* @param resultId The result id to match the result with the request.
* @return Whether the result was received.
*/
private boolean waitForResultTimedLocked(int resultId) {
long waitTimeMillis = RESULT_TIMEOUT;
final long startTimeMillis = SystemClock.uptimeMillis();
while (true) {
try {
if (mResultId == resultId) {
return true;
}
if (mResultId > resultId) {
return false;
}
final long elapsedTimeMillis = SystemClock.uptimeMillis() - startTimeMillis;
waitTimeMillis = RESULT_TIMEOUT - elapsedTimeMillis;
if (waitTimeMillis <= 0) {
return false;
}
mResultLock.wait(waitTimeMillis);
} catch (InterruptedException ie) {
/* ignore */
}
}
}
/**
* Callback exposed to JavaScript. Handles returning the result of a
* request to a waiting (or potentially timed out) thread.
*
* @param id The result id of the request as a {@link String}.
* @param result The result of the request as a {@link String}.
*/
@SuppressWarnings("unused")
public void onResult(String id, String result) {
final long resultId;
try {
resultId = Long.parseLong(id);
} catch (NumberFormatException e) {
return;
}
synchronized (mResultLock) {
if (resultId > mResultId) {
mResult = Boolean.parseBoolean(result);
mResultId = resultId;
}
mResultLock.notifyAll();
}
}
}
}