Merge "Add movement actions to JS accessibility." into jb-dev
This commit is contained in:
@@ -17,25 +17,37 @@
|
||||
package android.webkit;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Vibrator;
|
||||
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 {
|
||||
// Default result returned from AndroidVox. Using true here means if the
|
||||
// script fails, an accessibility service will always think that traversal
|
||||
// has succeeded.
|
||||
private static final String DEFAULT_ANDROIDVOX_RESULT = "true";
|
||||
|
||||
// The WebViewClassic this injector is responsible for managing.
|
||||
private final WebViewClassic mWebViewClassic;
|
||||
|
||||
@@ -47,10 +59,12 @@ class AccessibilityInjector {
|
||||
|
||||
// The Java objects that are exposed to JavaScript.
|
||||
private TextToSpeech mTextToSpeech;
|
||||
private CallbackHandler mCallback;
|
||||
|
||||
// Lazily loaded helper objects.
|
||||
private AccessibilityManager mAccessibilityManager;
|
||||
private AccessibilityInjectorFallback mAccessibilityInjector;
|
||||
private JSONObject mAccessibilityJSONObject;
|
||||
|
||||
// Whether the accessibility script has been injected into the current page.
|
||||
private boolean mAccessibilityScriptInjected;
|
||||
@@ -61,8 +75,11 @@ class AccessibilityInjector {
|
||||
@SuppressWarnings("unused")
|
||||
private static final int ACCESSIBILITY_SCRIPT_INJECTION_PROVIDED = 1;
|
||||
|
||||
// Aliases for Java objects exposed to JavaScript.
|
||||
private static final String ALIAS_ACCESSIBILITY_JS_INTERFACE = "accessibility";
|
||||
// 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 =
|
||||
@@ -73,6 +90,10 @@ class AccessibilityInjector {
|
||||
" 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}.
|
||||
@@ -99,6 +120,7 @@ class AccessibilityInjector {
|
||||
}
|
||||
|
||||
addTtsApis();
|
||||
addCallbackApis();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,6 +131,82 @@ class AccessibilityInjector {
|
||||
*/
|
||||
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 (mAccessibilityInjector != null) {
|
||||
// TODO: Implement actions for non-JS handler.
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,7 +359,7 @@ class AccessibilityInjector {
|
||||
final String pkgName = mContext.getPackageName();
|
||||
|
||||
mTextToSpeech = new TextToSpeech(mContext, null, null, pkgName + ".**webview**", true);
|
||||
mWebView.addJavascriptInterface(mTextToSpeech, ALIAS_ACCESSIBILITY_JS_INTERFACE);
|
||||
mWebView.addJavascriptInterface(mTextToSpeech, ALIAS_TTS_JS_INTERFACE);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -273,12 +371,30 @@ class AccessibilityInjector {
|
||||
return;
|
||||
}
|
||||
|
||||
mWebView.removeJavascriptInterface(ALIAS_ACCESSIBILITY_JS_INTERFACE);
|
||||
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
|
||||
@@ -347,4 +463,169 @@ class AccessibilityInjector {
|
||||
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:
|
||||
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:
|
||||
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);
|
||||
final String result = mCallback.performAction(mWebView, jsCode, DEFAULT_ANDROIDVOX_RESULT);
|
||||
|
||||
return ("true".equalsIgnoreCase(result));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = 200;
|
||||
|
||||
private final AtomicInteger mResultIdCounter = new AtomicInteger();
|
||||
private final Object mResultLock = new Object();
|
||||
private final String mInterfaceName;
|
||||
|
||||
private String mResult = null;
|
||||
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.
|
||||
* @param defaultResult The result to return if the action times out.
|
||||
* @return The result of the action, or false if it timed out.
|
||||
*/
|
||||
private String performAction(WebView webView, String code, String defaultResult) {
|
||||
final int resultId = mResultIdCounter.getAndIncrement();
|
||||
final String url = String.format(
|
||||
JAVASCRIPT_ACTION_TEMPLATE, mInterfaceName, resultId, code);
|
||||
webView.loadUrl(url);
|
||||
|
||||
return getResultAndClear(resultId, defaultResult);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the result of a request to perform an accessibility action.
|
||||
*
|
||||
* @param resultId The result id to match the result with the request.
|
||||
* @param defaultResult The default result to return on timeout.
|
||||
* @return The result of the request.
|
||||
*/
|
||||
private String getResultAndClear(int resultId, String defaultResult) {
|
||||
synchronized (mResultLock) {
|
||||
final boolean success = waitForResultTimedLocked(resultId);
|
||||
final String result = success ? mResult : defaultResult;
|
||||
clearResultLocked();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the result state.
|
||||
*/
|
||||
private void clearResultLocked() {
|
||||
mResultId = -1;
|
||||
mResult = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = result;
|
||||
mResultId = resultId;
|
||||
}
|
||||
mResultLock.notifyAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1686,6 +1686,10 @@ public class WebView extends AbsoluteLayout
|
||||
WebView.super.computeScroll();
|
||||
}
|
||||
|
||||
public boolean super_performAccessibilityAction(int action, Bundle arguments) {
|
||||
return WebView.super.performAccessibilityAction(action, arguments);
|
||||
}
|
||||
|
||||
public boolean super_performLongClick() {
|
||||
return WebView.super.performLongClick();
|
||||
}
|
||||
@@ -1938,6 +1942,11 @@ public class WebView extends AbsoluteLayout
|
||||
mProvider.getViewDelegate().onInitializeAccessibilityEvent(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean performAccessibilityAction(int action, Bundle arguments) {
|
||||
return mProvider.getViewDelegate().performAccessibilityAction(action, arguments);
|
||||
}
|
||||
|
||||
/** @hide */
|
||||
@Override
|
||||
protected void onDrawVerticalScrollBar(Canvas canvas, Drawable scrollBar,
|
||||
|
||||
@@ -1663,9 +1663,66 @@ public final class WebViewClassic implements WebViewProvider, WebViewProvider.Sc
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean performAccessibilityAction(int action, Bundle arguments) {
|
||||
if (!mWebView.isEnabled()) {
|
||||
// Only default actions are supported while disabled.
|
||||
return mWebViewPrivate.super_performAccessibilityAction(action, arguments);
|
||||
}
|
||||
|
||||
if (mAccessibilityInjector.supportsAccessibilityAction(action)) {
|
||||
return mAccessibilityInjector.performAccessibilityAction(action, arguments);
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD:
|
||||
case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD: {
|
||||
final int convertedContentHeight = contentToViewY(getContentHeight());
|
||||
final int adjustedViewHeight = getHeight() - mWebView.getPaddingTop()
|
||||
- mWebView.getPaddingBottom();
|
||||
final int maxScrollY = Math.max(convertedContentHeight - adjustedViewHeight, 0);
|
||||
final boolean canScrollBackward = (getScrollY() > 0);
|
||||
final boolean canScrollForward = ((getScrollY() - maxScrollY) > 0);
|
||||
if ((action == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) && canScrollBackward) {
|
||||
mWebView.scrollBy(0, adjustedViewHeight);
|
||||
return true;
|
||||
}
|
||||
if ((action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) && canScrollForward) {
|
||||
mWebView.scrollBy(0, -adjustedViewHeight);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return mWebViewPrivate.super_performAccessibilityAction(action, arguments);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
|
||||
if (!mWebView.isEnabled()) {
|
||||
// Only default actions are supported while disabled.
|
||||
return;
|
||||
}
|
||||
|
||||
info.setScrollable(isScrollableForAccessibility());
|
||||
|
||||
final int convertedContentHeight = contentToViewY(getContentHeight());
|
||||
final int adjustedViewHeight = getHeight() - mWebView.getPaddingTop()
|
||||
- mWebView.getPaddingBottom();
|
||||
final int maxScrollY = Math.max(convertedContentHeight - adjustedViewHeight, 0);
|
||||
final boolean canScrollBackward = (getScrollY() > 0);
|
||||
final boolean canScrollForward = ((getScrollY() - maxScrollY) > 0);
|
||||
|
||||
if (canScrollForward) {
|
||||
info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);
|
||||
}
|
||||
|
||||
if (canScrollForward) {
|
||||
info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD);
|
||||
}
|
||||
|
||||
mAccessibilityInjector.onInitializeAccessibilityNodeInfo(info);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -276,6 +276,8 @@ public interface WebViewProvider {
|
||||
|
||||
public void onInitializeAccessibilityEvent(AccessibilityEvent event);
|
||||
|
||||
public boolean performAccessibilityAction(int action, Bundle arguments);
|
||||
|
||||
public void setOverScrollMode(int mode);
|
||||
|
||||
public void setScrollBarStyle(int style);
|
||||
|
||||
Reference in New Issue
Block a user