// 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.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.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(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(JAVASCRIPT_ACTION_TEMPLATE, code, mInterfaceName,
resultId);
contentView.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();
}
}
}
}