blob: abc078be271f261a4580e6540c1452a185a0717c [file] [log] [blame]
/*
* 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.Handler;
import android.os.SystemClock;
import android.provider.Settings;
import android.speech.tts.TextToSpeech;
import android.speech.tts.TextToSpeech.Engine;
import android.speech.tts.TextToSpeech.OnInitListener;
import android.speech.tts.UtteranceProgressListener;
import android.util.Log;
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.HashMap;
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 {
private static final String TAG = AccessibilityInjector.class.getSimpleName();
private static boolean DEBUG = false;
// 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 TextToSpeechWrapper 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 =
"(function() {" +
" if ((typeof(cvox) != 'undefined')" +
" && (cvox != null)" +
" && (typeof(cvox.ChromeVox) != 'undefined')" +
" && (cvox.ChromeVox != null)" +
" && (typeof(cvox.AndroidVox) != 'undefined')" +
" && (cvox.AndroidVox != null)" +
" && cvox.ChromeVox.isActive) {" +
" return cvox.AndroidVox.performAction('%1s');" +
" } else {" +
" return false;" +
" }" +
"})()";
// JS code used to shut down an active AndroidVox instance.
private static final String TOGGLE_CVOX_TEMPLATE =
"javascript:(function() {" +
" if ((typeof(cvox) != 'undefined')" +
" && (cvox != null)" +
" && (typeof(cvox.ChromeVox) != 'undefined')" +
" && (cvox.ChromeVox != null)" +
" && (typeof(cvox.ChromeVox.host) != 'undefined')" +
" && (cvox.ChromeVox.host != null)) {" +
" cvox.ChromeVox.host.activateOrDeactivateChromeVox(%b);" +
" }" +
"})();";
/**
* 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);
}
/**
* If JavaScript is enabled, pauses or resumes AndroidVox.
*
* @param enabled Whether feedback should be enabled.
*/
public void toggleAccessibilityFeedback(boolean enabled) {
if (!isAccessibilityEnabled() || !isJavaScriptEnabled()) {
return;
}
toggleAndroidVox(enabled);
if (!enabled && (mTextToSpeech != null)) {
mTextToSpeech.stop();
}
}
/**
* Attempts to load scripting interfaces for accessibility.
* <p>
* This should only be called before a page loads.
*/
public void addAccessibilityApisIfNecessary() {
if (!isAccessibilityEnabled() || !isJavaScriptEnabled()) {
return;
}
addTtsApis();
addCallbackApis();
}
/**
* Attempts to unload scripting interfaces for accessibility.
* <p>
* This should only be called before a page loads.
*/
private void removeAccessibilityApisIfNecessary() {
removeTtsApis();
removeCallbackApis();
}
/**
* Destroys this accessibility injector.
*/
public void destroy() {
if (mTextToSpeech != null) {
mTextToSpeech.shutdown();
mTextToSpeech = null;
}
if (mCallback != null) {
mCallback = null;
}
}
private void toggleAndroidVox(boolean state) {
if (!mAccessibilityScriptInjected) {
return;
}
final String code = String.format(TOGGLE_CVOX_TEMPLATE, state);
mWebView.loadUrl(code);
}
/**
* 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.
* <p>
* This must not be called from the main thread.
*
* @param selection The selection string.
* @param token The selection request token.
*/
public void onSelectionStringChangedWebCoreThread(String selection, int token) {
if (mAccessibilityInjectorFallback != null) {
mAccessibilityInjectorFallback.onSelectionStringChangedWebCoreThread(selection, token);
}
}
/**
* Prepares for injecting accessibility scripts into a new page.
*
* @param url The URL that will be loaded.
*/
public void onPageStarted(String url) {
mAccessibilityScriptInjected = false;
if (DEBUG) {
Log.w(TAG, "[" + mWebView.hashCode() + "] Started loading new page");
}
addAccessibilityApisIfNecessary();
}
/**
* 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()) {
toggleFallbackAccessibilityInjector(false);
return;
}
toggleFallbackAccessibilityInjector(true);
if (shouldInjectJavaScript(url)) {
// If we're supposed to use the JS screen reader, request a
// callback to confirm that CallbackHandler is working.
if (DEBUG) {
Log.d(TAG, "[" + mWebView.hashCode() + "] Request callback ");
}
mCallback.requestCallback(mWebView, mInjectScriptRunnable);
}
}
/**
* Runnable used to inject the JavaScript-based screen reader if the
* {@link CallbackHandler} API was successfully exposed to JavaScript.
*/
private Runnable mInjectScriptRunnable = new Runnable() {
@Override
public void run() {
if (DEBUG) {
Log.d(TAG, "[" + mWebView.hashCode() + "] Received callback");
}
injectJavaScript();
}
};
/**
* Called by {@link #mInjectScriptRunnable} to inject the JavaScript-based
* screen reader after confirming that the {@link CallbackHandler} API is
* functional.
*/
private void injectJavaScript() {
toggleFallbackAccessibilityInjector(false);
if (!mAccessibilityScriptInjected) {
mAccessibilityScriptInjected = true;
final String injectionUrl = getScreenReaderInjectionUrl();
mWebView.loadUrl(injectionUrl);
if (DEBUG) {
Log.d(TAG, "[" + mWebView.hashCode() + "] Loading screen reader into WebView");
}
} else {
if (DEBUG) {
Log.w(TAG, "[" + mWebView.hashCode() + "] Attempted to inject screen reader twice");
}
}
}
/**
* Adjusts the accessibility injection state to reflect changes in the
* JavaScript enabled state.
*
* @param enabled Whether JavaScript is enabled.
*/
public void updateJavaScriptEnabled(boolean enabled) {
if (enabled) {
addAccessibilityApisIfNecessary();
} else {
removeAccessibilityApisIfNecessary();
}
// We have to reload the page after adding or removing APIs.
mWebView.reload();
}
/**
* 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) {
mTextToSpeech = new TextToSpeechWrapper(mContext);
}
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) {
mTextToSpeech.stop();
mTextToSpeech.shutdown();
mTextToSpeech = null;
}
mWebView.removeJavascriptInterface(ALIAS_TTS_JS_INTERFACE);
}
private void addCallbackApis() {
if (mCallback == null) {
mCallback = new CallbackHandler(ALIAS_TRAVERSAL_JS_INTERFACE);
}
mWebView.addJavascriptInterface(mCallback, ALIAS_TRAVERSAL_JS_INTERFACE);
}
private void removeCallbackApis() {
if (mCallback != null) {
mCallback = null;
}
mWebView.removeJavascriptInterface(ALIAS_TRAVERSAL_JS_INTERFACE);
}
/**
* 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.
} catch (IllegalArgumentException e) {
// Catch badly-formed URLs.
}
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() {
final WebSettings settings = mWebView.getSettings();
if (settings == null) {
return false;
}
return settings.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);
}
/**
* Used to protect the TextToSpeech class, only exposing the methods we want to expose.
*/
private static class TextToSpeechWrapper {
private static final String WRAP_TAG = TextToSpeechWrapper.class.getSimpleName();
/** Lock used to control access to the TextToSpeech object. */
private final Object mTtsLock = new Object();
private final HashMap<String, String> mTtsParams;
private final TextToSpeech mTextToSpeech;
/**
* Whether this wrapper is ready to speak. If this is {@code true} then
* {@link #mShutdown} is guaranteed to be {@code false}.
*/
private volatile boolean mReady;
/**
* Whether this wrapper was shut down. If this is {@code true} then
* {@link #mReady} is guaranteed to be {@code false}.
*/
private volatile boolean mShutdown;
public TextToSpeechWrapper(Context context) {
if (DEBUG) {
Log.d(WRAP_TAG, "[" + hashCode() + "] Initializing text-to-speech on thread "
+ Thread.currentThread().getId() + "...");
}
final String pkgName = context.getPackageName();
mReady = false;
mShutdown = false;
mTtsParams = new HashMap<String, String>();
mTtsParams.put(Engine.KEY_PARAM_UTTERANCE_ID, WRAP_TAG);
mTextToSpeech = new TextToSpeech(
context, mInitListener, null, pkgName + ".**webview**", true);
mTextToSpeech.setOnUtteranceProgressListener(mErrorListener);
}
@JavascriptInterface
@SuppressWarnings("unused")
public boolean isSpeaking() {
synchronized (mTtsLock) {
if (!mReady) {
return false;
}
return mTextToSpeech.isSpeaking();
}
}
@JavascriptInterface
@SuppressWarnings("unused")
public int speak(String text, int queueMode, HashMap<String, String> params) {
synchronized (mTtsLock) {
if (!mReady) {
if (DEBUG) {
Log.w(WRAP_TAG, "[" + hashCode() + "] Attempted to speak before TTS init");
}
return TextToSpeech.ERROR;
} else {
if (DEBUG) {
Log.i(WRAP_TAG, "[" + hashCode() + "] Speak called from JS binder");
}
}
return mTextToSpeech.speak(text, queueMode, params);
}
}
@JavascriptInterface
@SuppressWarnings("unused")
public int stop() {
synchronized (mTtsLock) {
if (!mReady) {
if (DEBUG) {
Log.w(WRAP_TAG, "[" + hashCode() + "] Attempted to stop before initialize");
}
return TextToSpeech.ERROR;
} else {
if (DEBUG) {
Log.i(WRAP_TAG, "[" + hashCode() + "] Stop called from JS binder");
}
}
return mTextToSpeech.stop();
}
}
@SuppressWarnings("unused")
protected void shutdown() {
synchronized (mTtsLock) {
if (!mReady) {
if (DEBUG) {
Log.w(WRAP_TAG, "[" + hashCode() + "] Called shutdown before initialize");
}
} else {
if (DEBUG) {
Log.i(WRAP_TAG, "[" + hashCode() + "] Shutting down text-to-speech from "
+ "thread " + Thread.currentThread().getId() + "...");
}
}
mShutdown = true;
mReady = false;
mTextToSpeech.shutdown();
}
}
private final OnInitListener mInitListener = new OnInitListener() {
@Override
public void onInit(int status) {
synchronized (mTtsLock) {
if (!mShutdown && (status == TextToSpeech.SUCCESS)) {
if (DEBUG) {
Log.d(WRAP_TAG, "[" + TextToSpeechWrapper.this.hashCode()
+ "] Initialized successfully");
}
mReady = true;
} else {
if (DEBUG) {
Log.w(WRAP_TAG, "[" + TextToSpeechWrapper.this.hashCode()
+ "] Failed to initialize");
}
mReady = false;
}
}
}
};
private final UtteranceProgressListener mErrorListener = new UtteranceProgressListener() {
@Override
public void onStart(String utteranceId) {
// Do nothing.
}
@Override
public void onError(String utteranceId) {
if (DEBUG) {
Log.w(WRAP_TAG, "[" + TextToSpeechWrapper.this.hashCode()
+ "] Failed to speak utterance");
}
}
@Override
public void onDone(String utteranceId) {
// Do nothing.
}
};
}
/**
* 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 final Handler mMainHandler;
private Runnable mCallbackRunnable;
private boolean mResult = false;
private int mResultId = -1;
private CallbackHandler(String interfaceName) {
mInterfaceName = interfaceName;
mMainHandler = new Handler();
}
/**
* 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) {
final long startTimeMillis = SystemClock.uptimeMillis();
if (DEBUG) {
Log.d(TAG, "Waiting for CVOX result with ID " + resultId + "...");
}
while (true) {
// Fail if we received a callback from the future.
if (mResultId > resultId) {
if (DEBUG) {
Log.w(TAG, "Aborted CVOX result");
}
return false;
}
final long elapsedTimeMillis = (SystemClock.uptimeMillis() - startTimeMillis);
// Succeed if we received the callback we were expecting.
if (DEBUG) {
Log.w(TAG, "Check " + mResultId + " versus expected " + resultId);
}
if (mResultId == resultId) {
if (DEBUG) {
Log.w(TAG, "Received CVOX result after " + elapsedTimeMillis + " ms");
}
return true;
}
final long waitTimeMillis = (RESULT_TIMEOUT - elapsedTimeMillis);
// Fail if we've already exceeded the timeout.
if (waitTimeMillis <= 0) {
if (DEBUG) {
Log.w(TAG, "Timed out while waiting for CVOX result");
}
return false;
}
try {
if (DEBUG) {
Log.w(TAG, "Start waiting...");
}
mResultLock.wait(waitTimeMillis);
} catch (InterruptedException ie) {
if (DEBUG) {
Log.w(TAG, "Interrupted while waiting for CVOX result");
}
}
}
}
/**
* 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}.
*/
@JavascriptInterface
@SuppressWarnings("unused")
public void onResult(String id, String result) {
if (DEBUG) {
Log.w(TAG, "Saw CVOX result of '" + result + "' for ID " + id);
}
final int resultId;
try {
resultId = Integer.parseInt(id);
} catch (NumberFormatException e) {
return;
}
synchronized (mResultLock) {
if (resultId > mResultId) {
mResult = Boolean.parseBoolean(result);
mResultId = resultId;
} else {
if (DEBUG) {
Log.w(TAG, "Result with ID " + resultId + " was stale vesus " + mResultId);
}
}
mResultLock.notifyAll();
}
}
/**
* Requests a callback to ensure that the JavaScript interface for this
* object has been added successfully.
*
* @param webView The web view to request a callback from.
* @param callbackRunnable Runnable to execute if a callback is received.
*/
public void requestCallback(WebView webView, Runnable callbackRunnable) {
mCallbackRunnable = callbackRunnable;
webView.loadUrl("javascript:(function() { " + mInterfaceName + ".callback(); })();");
}
@JavascriptInterface
@SuppressWarnings("unused")
public void callback() {
if (mCallbackRunnable != null) {
mMainHandler.post(mCallbackRunnable);
mCallbackRunnable = null;
}
}
}
}