Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 9a9a041c authored by Charles Chen's avatar Charles Chen Committed by Android (Google) Code Review
Browse files

Merge "Add movement actions to JS accessibility." into jb-dev

parents 3d6f7ead 448902d7
Loading
Loading
Loading
Loading
+286 −5
Original line number Diff line number Diff line
@@ -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();
            }
        }
    }
}
+9 −0
Original line number Diff line number Diff line
@@ -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,
+57 −0
Original line number Diff line number Diff line
@@ -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
+2 −0
Original line number Diff line number Diff line
@@ -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);