1717package android .webkit ;
1818
1919import android .content .Context ;
20- import android .os .Vibrator ;
20+ import android .os .Bundle ;
21+ import android .os .SystemClock ;
2122import android .provider .Settings ;
2223import android .speech .tts .TextToSpeech ;
2324import android .view .KeyEvent ;
25+ import android .view .View ;
2426import android .view .accessibility .AccessibilityManager ;
27+ import android .view .accessibility .AccessibilityNodeInfo ;
2528import android .webkit .WebViewCore .EventHub ;
2629
2730import org .apache .http .NameValuePair ;
2831import org .apache .http .client .utils .URLEncodedUtils ;
32+ import org .json .JSONException ;
33+ import org .json .JSONObject ;
2934
3035import java .net .URI ;
3136import java .net .URISyntaxException ;
37+ import java .util .Iterator ;
3238import java .util .List ;
39+ import java .util .concurrent .atomic .AtomicInteger ;
3340
3441/**
3542 * Handles injecting accessibility JavaScript and related JavaScript -> Java
3643 * APIs.
3744 */
3845class 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