Skip to content

Commit 9a9a041

Browse files
Charles ChenAndroid (Google) Code Review
authored andcommitted
Merge "Add movement actions to JS accessibility." into jb-dev
2 parents 3d6f7ea + 448902d commit 9a9a041

File tree

4 files changed

+354
-5
lines changed

4 files changed

+354
-5
lines changed

core/java/android/webkit/AccessibilityInjector.java

Lines changed: 286 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,25 +17,37 @@
1717
package android.webkit;
1818

1919
import android.content.Context;
20-
import android.os.Vibrator;
20+
import android.os.Bundle;
21+
import android.os.SystemClock;
2122
import android.provider.Settings;
2223
import android.speech.tts.TextToSpeech;
2324
import android.view.KeyEvent;
25+
import android.view.View;
2426
import android.view.accessibility.AccessibilityManager;
27+
import android.view.accessibility.AccessibilityNodeInfo;
2528
import android.webkit.WebViewCore.EventHub;
2629

2730
import org.apache.http.NameValuePair;
2831
import org.apache.http.client.utils.URLEncodedUtils;
32+
import org.json.JSONException;
33+
import org.json.JSONObject;
2934

3035
import java.net.URI;
3136
import java.net.URISyntaxException;
37+
import java.util.Iterator;
3238
import java.util.List;
39+
import java.util.concurrent.atomic.AtomicInteger;
3340

3441
/**
3542
* Handles injecting accessibility JavaScript and related JavaScript -> Java
3643
* APIs.
3744
*/
3845
class AccessibilityInjector {
46+
// Default result returned from AndroidVox. Using true here means if the
47+
// script fails, an accessibility service will always think that traversal
48+
// has succeeded.
49+
private static final String DEFAULT_ANDROIDVOX_RESULT = "true";
50+
3951
// The WebViewClassic this injector is responsible for managing.
4052
private final WebViewClassic mWebViewClassic;
4153

@@ -47,10 +59,12 @@ class AccessibilityInjector {
4759

4860
// The Java objects that are exposed to JavaScript.
4961
private TextToSpeech mTextToSpeech;
62+
private CallbackHandler mCallback;
5063

5164
// Lazily loaded helper objects.
5265
private AccessibilityManager mAccessibilityManager;
5366
private AccessibilityInjectorFallback mAccessibilityInjector;
67+
private JSONObject mAccessibilityJSONObject;
5468

5569
// Whether the accessibility script has been injected into the current page.
5670
private boolean mAccessibilityScriptInjected;
@@ -61,8 +75,11 @@ class AccessibilityInjector {
6175
@SuppressWarnings("unused")
6276
private static final int ACCESSIBILITY_SCRIPT_INJECTION_PROVIDED = 1;
6377

64-
// Aliases for Java objects exposed to JavaScript.
65-
private static final String ALIAS_ACCESSIBILITY_JS_INTERFACE = "accessibility";
78+
// Alias for TTS API exposed to JavaScript.
79+
private static final String ALIAS_TTS_JS_INTERFACE = "accessibility";
80+
81+
// Alias for traversal callback exposed to JavaScript.
82+
private static final String ALIAS_TRAVERSAL_JS_INTERFACE = "accessibilityTraversal";
6683

6784
// Template for JavaScript that injects a screen-reader.
6885
private static final String ACCESSIBILITY_SCREEN_READER_JAVASCRIPT_TEMPLATE =
@@ -73,6 +90,10 @@ class AccessibilityInjector {
7390
" document.getElementsByTagName('head')[0].appendChild(chooser);" +
7491
" })();";
7592

93+
// Template for JavaScript that performs AndroidVox actions.
94+
private static final String ACCESSIBILITY_ANDROIDVOX_TEMPLATE =
95+
"cvox.AndroidVox.performAction('%1s')";
96+
7697
/**
7798
* Creates an instance of the AccessibilityInjector based on
7899
* {@code webViewClassic}.
@@ -99,6 +120,7 @@ public void addAccessibilityApisIfNecessary() {
99120
}
100121

101122
addTtsApis();
123+
addCallbackApis();
102124
}
103125

104126
/**
@@ -109,6 +131,82 @@ public void addAccessibilityApisIfNecessary() {
109131
*/
110132
public void removeAccessibilityApisIfNecessary() {
111133
removeTtsApis();
134+
removeCallbackApis();
135+
}
136+
137+
/**
138+
* Initializes an {@link AccessibilityNodeInfo} with the actions and
139+
* movement granularity levels supported by this
140+
* {@link AccessibilityInjector}.
141+
* <p>
142+
* If an action identifier is added in this method, this
143+
* {@link AccessibilityInjector} should also return {@code true} from
144+
* {@link #supportsAccessibilityAction(int)}.
145+
* </p>
146+
*
147+
* @param info The info to initialize.
148+
* @see View#onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo)
149+
*/
150+
public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
151+
info.setMovementGranularities(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER
152+
| AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD
153+
| AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE
154+
| AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH
155+
| AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PAGE);
156+
info.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY);
157+
info.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY);
158+
info.addAction(AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT);
159+
info.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT);
160+
info.addAction(AccessibilityNodeInfo.ACTION_CLICK);
161+
info.setClickable(true);
162+
}
163+
164+
/**
165+
* Returns {@code true} if this {@link AccessibilityInjector} should handle
166+
* the specified action.
167+
*
168+
* @param action An accessibility action identifier.
169+
* @return {@code true} if this {@link AccessibilityInjector} should handle
170+
* the specified action.
171+
*/
172+
public boolean supportsAccessibilityAction(int action) {
173+
switch (action) {
174+
case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY:
175+
case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY:
176+
case AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT:
177+
case AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT:
178+
case AccessibilityNodeInfo.ACTION_CLICK:
179+
return true;
180+
default:
181+
return false;
182+
}
183+
}
184+
185+
/**
186+
* Performs the specified accessibility action.
187+
*
188+
* @param action The identifier of the action to perform.
189+
* @param arguments The action arguments, or {@code null} if no arguments.
190+
* @return {@code true} if the action was successful.
191+
* @see View#performAccessibilityAction(int, Bundle)
192+
*/
193+
public boolean performAccessibilityAction(int action, Bundle arguments) {
194+
if (!isAccessibilityEnabled()) {
195+
mAccessibilityScriptInjected = false;
196+
toggleFallbackAccessibilityInjector(false);
197+
return false;
198+
}
199+
200+
if (mAccessibilityScriptInjected) {
201+
return sendActionToAndroidVox(action, arguments);
202+
}
203+
204+
if (mAccessibilityInjector != null) {
205+
// TODO: Implement actions for non-JS handler.
206+
return false;
207+
}
208+
209+
return false;
112210
}
113211

114212
/**
@@ -261,7 +359,7 @@ private void addTtsApis() {
261359
final String pkgName = mContext.getPackageName();
262360

263361
mTextToSpeech = new TextToSpeech(mContext, null, null, pkgName + ".**webview**", true);
264-
mWebView.addJavascriptInterface(mTextToSpeech, ALIAS_ACCESSIBILITY_JS_INTERFACE);
362+
mWebView.addJavascriptInterface(mTextToSpeech, ALIAS_TTS_JS_INTERFACE);
265363
}
266364

267365
/**
@@ -273,12 +371,30 @@ private void removeTtsApis() {
273371
return;
274372
}
275373

276-
mWebView.removeJavascriptInterface(ALIAS_ACCESSIBILITY_JS_INTERFACE);
374+
mWebView.removeJavascriptInterface(ALIAS_TTS_JS_INTERFACE);
277375
mTextToSpeech.stop();
278376
mTextToSpeech.shutdown();
279377
mTextToSpeech = null;
280378
}
281379

380+
private void addCallbackApis() {
381+
if (mCallback != null) {
382+
return;
383+
}
384+
385+
mCallback = new CallbackHandler(ALIAS_TRAVERSAL_JS_INTERFACE);
386+
mWebView.addJavascriptInterface(mCallback, ALIAS_TRAVERSAL_JS_INTERFACE);
387+
}
388+
389+
private void removeCallbackApis() {
390+
if (mCallback == null) {
391+
return;
392+
}
393+
394+
mWebView.removeJavascriptInterface(ALIAS_TRAVERSAL_JS_INTERFACE);
395+
mCallback = null;
396+
}
397+
282398
/**
283399
* Returns the script injection preference requested by the URL, or
284400
* {@link #ACCESSIBILITY_SCRIPT_INJECTION_UNDEFINED} if the page has no
@@ -347,4 +463,169 @@ private boolean isJavaScriptEnabled() {
347463
private boolean isAccessibilityEnabled() {
348464
return mAccessibilityManager.isEnabled();
349465
}
466+
467+
/**
468+
* Packs an accessibility action into a JSON object and sends it to AndroidVox.
469+
*
470+
* @param action The action identifier.
471+
* @param arguments The action arguments, if applicable.
472+
* @return The result of the action.
473+
*/
474+
private boolean sendActionToAndroidVox(int action, Bundle arguments) {
475+
if (mAccessibilityJSONObject == null) {
476+
mAccessibilityJSONObject = new JSONObject();
477+
} else {
478+
// Remove all keys from the object.
479+
final Iterator<?> keys = mAccessibilityJSONObject.keys();
480+
while (keys.hasNext()) {
481+
keys.next();
482+
keys.remove();
483+
}
484+
}
485+
486+
try {
487+
mAccessibilityJSONObject.accumulate("action", action);
488+
489+
switch (action) {
490+
case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY:
491+
case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY:
492+
final int granularity = arguments.getInt(
493+
AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT);
494+
mAccessibilityJSONObject.accumulate("granularity", granularity);
495+
break;
496+
case AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT:
497+
case AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT:
498+
final String element = arguments.getString(
499+
AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING);
500+
mAccessibilityJSONObject.accumulate("element", element);
501+
break;
502+
}
503+
} catch (JSONException e) {
504+
return false;
505+
}
506+
507+
final String jsonString = mAccessibilityJSONObject.toString();
508+
final String jsCode = String.format(ACCESSIBILITY_ANDROIDVOX_TEMPLATE, jsonString);
509+
final String result = mCallback.performAction(mWebView, jsCode, DEFAULT_ANDROIDVOX_RESULT);
510+
511+
return ("true".equalsIgnoreCase(result));
512+
}
513+
514+
/**
515+
* Exposes result interface to JavaScript.
516+
*/
517+
private static class CallbackHandler {
518+
private static final String JAVASCRIPT_ACTION_TEMPLATE =
519+
"javascript:(function() { %s.onResult(%d, %s); })();";
520+
521+
// Time in milliseconds to wait for a result before failing.
522+
private static final long RESULT_TIMEOUT = 200;
523+
524+
private final AtomicInteger mResultIdCounter = new AtomicInteger();
525+
private final Object mResultLock = new Object();
526+
private final String mInterfaceName;
527+
528+
private String mResult = null;
529+
private long mResultId = -1;
530+
531+
private CallbackHandler(String interfaceName) {
532+
mInterfaceName = interfaceName;
533+
}
534+
535+
/**
536+
* Performs an action and attempts to wait for a result.
537+
*
538+
* @param webView The WebView to perform the action on.
539+
* @param code JavaScript code that evaluates to a result.
540+
* @param defaultResult The result to return if the action times out.
541+
* @return The result of the action, or false if it timed out.
542+
*/
543+
private String performAction(WebView webView, String code, String defaultResult) {
544+
final int resultId = mResultIdCounter.getAndIncrement();
545+
final String url = String.format(
546+
JAVASCRIPT_ACTION_TEMPLATE, mInterfaceName, resultId, code);
547+
webView.loadUrl(url);
548+
549+
return getResultAndClear(resultId, defaultResult);
550+
}
551+
552+
/**
553+
* Gets the result of a request to perform an accessibility action.
554+
*
555+
* @param resultId The result id to match the result with the request.
556+
* @param defaultResult The default result to return on timeout.
557+
* @return The result of the request.
558+
*/
559+
private String getResultAndClear(int resultId, String defaultResult) {
560+
synchronized (mResultLock) {
561+
final boolean success = waitForResultTimedLocked(resultId);
562+
final String result = success ? mResult : defaultResult;
563+
clearResultLocked();
564+
return result;
565+
}
566+
}
567+
568+
/**
569+
* Clears the result state.
570+
*/
571+
private void clearResultLocked() {
572+
mResultId = -1;
573+
mResult = null;
574+
}
575+
576+
/**
577+
* Waits up to a given bound for a result of a request and returns it.
578+
*
579+
* @param resultId The result id to match the result with the request.
580+
* @return Whether the result was received.
581+
*/
582+
private boolean waitForResultTimedLocked(int resultId) {
583+
long waitTimeMillis = RESULT_TIMEOUT;
584+
final long startTimeMillis = SystemClock.uptimeMillis();
585+
while (true) {
586+
try {
587+
if (mResultId == resultId) {
588+
return true;
589+
}
590+
if (mResultId > resultId) {
591+
return false;
592+
}
593+
final long elapsedTimeMillis = SystemClock.uptimeMillis() - startTimeMillis;
594+
waitTimeMillis = RESULT_TIMEOUT - elapsedTimeMillis;
595+
if (waitTimeMillis <= 0) {
596+
return false;
597+
}
598+
mResultLock.wait(waitTimeMillis);
599+
} catch (InterruptedException ie) {
600+
/* ignore */
601+
}
602+
}
603+
}
604+
605+
/**
606+
* Callback exposed to JavaScript. Handles returning the result of a
607+
* request to a waiting (or potentially timed out) thread.
608+
*
609+
* @param id The result id of the request as a {@link String}.
610+
* @param result The result of the request as a {@link String}.
611+
*/
612+
@SuppressWarnings("unused")
613+
public void onResult(String id, String result) {
614+
final long resultId;
615+
616+
try {
617+
resultId = Long.parseLong(id);
618+
} catch (NumberFormatException e) {
619+
return;
620+
}
621+
622+
synchronized (mResultLock) {
623+
if (resultId > mResultId) {
624+
mResult = result;
625+
mResultId = resultId;
626+
}
627+
mResultLock.notifyAll();
628+
}
629+
}
630+
}
350631
}

0 commit comments

Comments
 (0)