| // 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.content.Context; |
| import android.os.Bundle; |
| import android.os.SystemClock; |
| import android.view.accessibility.AccessibilityNodeInfo; |
| |
| import org.chromium.content.browser.ContentViewCore; |
| import org.chromium.content.browser.JavascriptInterface; |
| import org.json.JSONException; |
| import org.json.JSONObject; |
| |
| import java.util.Iterator; |
| import java.util.Locale; |
| import java.util.concurrent.atomic.AtomicInteger; |
| |
| /** |
| * Handles injecting accessibility Javascript and related Javascript -> Java APIs for JB and newer |
| * devices. |
| */ |
| class JellyBeanAccessibilityInjector extends AccessibilityInjector { |
| private CallbackHandler mCallback; |
| private JSONObject mAccessibilityJSONObject; |
| |
| private static final String ALIAS_TRAVERSAL_JS_INTERFACE = "accessibilityTraversal"; |
| |
| // Template for JavaScript that performs AndroidVox actions. |
| private static final String ACCESSIBILITY_ANDROIDVOX_TEMPLATE = |
| "cvox.AndroidVox.performAction('%1s')"; |
| |
| /** |
| * Constructs an instance of the JellyBeanAccessibilityInjector. |
| * @param view The ContentViewCore that this AccessibilityInjector manages. |
| */ |
| protected JellyBeanAccessibilityInjector(ContentViewCore view) { |
| super(view); |
| } |
| |
| @Override |
| 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); |
| } |
| |
| @Override |
| public boolean supportsAccessibilityAction(int action) { |
| if (action == AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY || |
| action == AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY || |
| action == AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT || |
| action == AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT || |
| action == AccessibilityNodeInfo.ACTION_CLICK) { |
| return true; |
| } |
| |
| return false; |
| } |
| |
| @Override |
| public boolean performAccessibilityAction(int action, Bundle arguments) { |
| if (!accessibilityIsAvailable() || !mContentViewCore.isAlive() || |
| !mInjectedScriptEnabled || !mScriptInjected) { |
| return false; |
| } |
| |
| boolean actionSuccessful = sendActionToAndroidVox(action, arguments); |
| |
| if (actionSuccessful) mContentViewCore.getWebContents().showImeIfNeeded(); |
| |
| return actionSuccessful; |
| } |
| |
| @Override |
| protected void addAccessibilityApis() { |
| super.addAccessibilityApis(); |
| |
| Context context = mContentViewCore.getContext(); |
| if (context != null && mCallback == null) { |
| mCallback = new CallbackHandler(ALIAS_TRAVERSAL_JS_INTERFACE); |
| mContentViewCore.addJavascriptInterface(mCallback, ALIAS_TRAVERSAL_JS_INTERFACE); |
| } |
| } |
| |
| @Override |
| protected void removeAccessibilityApis() { |
| super.removeAccessibilityApis(); |
| |
| if (mCallback != null) { |
| mContentViewCore.removeJavascriptInterface(ALIAS_TRAVERSAL_JS_INTERFACE); |
| mCallback = null; |
| } |
| } |
| |
| /** |
| * 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 (mCallback == null) return false; |
| 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); |
| if (arguments != null) { |
| if (action == AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY || |
| action == AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY) { |
| final int granularity = arguments.getInt( |
| AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT); |
| mAccessibilityJSONObject.accumulate("granularity", granularity); |
| } else if (action == AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT || |
| action == AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT) { |
| final String element = arguments.getString( |
| AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING); |
| mAccessibilityJSONObject.accumulate("element", element); |
| } |
| } |
| } catch (JSONException ex) { |
| return false; |
| } |
| |
| final String jsonString = mAccessibilityJSONObject.toString(); |
| final String jsCode = String.format(Locale.US, ACCESSIBILITY_ANDROIDVOX_TEMPLATE, |
| jsonString); |
| return mCallback.performAction(mContentViewCore, jsCode); |
| } |
| |
| private static class CallbackHandler { |
| private static final String JAVASCRIPT_ACTION_TEMPLATE = |
| "(function() {" + |
| " retVal = false;" + |
| " try {" + |
| " retVal = %s;" + |
| " } catch (e) {" + |
| " retVal = false;" + |
| " }" + |
| " %s.onResult(%d, retVal);" + |
| "})()"; |
| |
| // 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 contentView The ContentViewCore to perform the action on. |
| * @param code Javascript code that evaluates to a result. |
| * @return The result of the action. |
| */ |
| private boolean performAction(ContentViewCore contentView, String code) { |
| final int resultId = mResultIdCounter.getAndIncrement(); |
| final String js = String.format(Locale.US, JAVASCRIPT_ACTION_TEMPLATE, code, |
| mInterfaceName, resultId); |
| contentView.getWebContents().evaluateJavaScript(js, null); |
| |
| 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 a request as a {@link String}. |
| */ |
| @JavascriptInterface |
| @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(); |
| } |
| } |
| } |
| } |