1 | // Copyright (c) 2012 The Chromium Authors. All rights reserved. |
2 | // Use of this source code is governed by a BSD-style license that can be |
3 | // found in the LICENSE file. |
4 | |
5 | package org.chromium.content.browser.accessibility; |
6 | |
7 | import android.content.Context; |
8 | import android.os.Bundle; |
9 | import android.os.SystemClock; |
10 | import android.view.accessibility.AccessibilityNodeInfo; |
11 | |
12 | import org.chromium.content.browser.ContentViewCore; |
13 | import org.chromium.content.browser.JavascriptInterface; |
14 | import org.json.JSONException; |
15 | import org.json.JSONObject; |
16 | |
17 | import java.util.Iterator; |
18 | import java.util.concurrent.atomic.AtomicInteger; |
19 | |
20 | /** |
21 | * Handles injecting accessibility Javascript and related Javascript -> Java APIs for JB and newer |
22 | * devices. |
23 | */ |
24 | class JellyBeanAccessibilityInjector extends AccessibilityInjector { |
25 | private CallbackHandler mCallback; |
26 | private JSONObject mAccessibilityJSONObject; |
27 | |
28 | private static final String ALIAS_TRAVERSAL_JS_INTERFACE = "accessibilityTraversal"; |
29 | |
30 | // Template for JavaScript that performs AndroidVox actions. |
31 | private static final String ACCESSIBILITY_ANDROIDVOX_TEMPLATE = |
32 | "cvox.AndroidVox.performAction('%1s')"; |
33 | |
34 | /** |
35 | * Constructs an instance of the JellyBeanAccessibilityInjector. |
36 | * @param view The ContentViewCore that this AccessibilityInjector manages. |
37 | */ |
38 | protected JellyBeanAccessibilityInjector(ContentViewCore view) { |
39 | super(view); |
40 | } |
41 | |
42 | @Override |
43 | public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { |
44 | info.setMovementGranularities(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER | |
45 | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD | |
46 | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE | |
47 | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH | |
48 | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PAGE); |
49 | info.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY); |
50 | info.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY); |
51 | info.addAction(AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT); |
52 | info.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT); |
53 | info.addAction(AccessibilityNodeInfo.ACTION_CLICK); |
54 | info.setClickable(true); |
55 | } |
56 | |
57 | @Override |
58 | public boolean supportsAccessibilityAction(int action) { |
59 | if (action == AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY || |
60 | action == AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY || |
61 | action == AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT || |
62 | action == AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT || |
63 | action == AccessibilityNodeInfo.ACTION_CLICK) { |
64 | return true; |
65 | } |
66 | |
67 | return false; |
68 | } |
69 | |
70 | @Override |
71 | public boolean performAccessibilityAction(int action, Bundle arguments) { |
72 | if (!accessibilityIsAvailable() || !mContentViewCore.isAlive() || |
73 | !mInjectedScriptEnabled || !mScriptInjected) { |
74 | return false; |
75 | } |
76 | |
77 | boolean actionSuccessful = sendActionToAndroidVox(action, arguments); |
78 | |
79 | if (actionSuccessful) mContentViewCore.showImeIfNeeded(); |
80 | |
81 | return actionSuccessful; |
82 | } |
83 | |
84 | @Override |
85 | protected void addAccessibilityApis() { |
86 | super.addAccessibilityApis(); |
87 | |
88 | Context context = mContentViewCore.getContext(); |
89 | if (context != null && mCallback == null) { |
90 | mCallback = new CallbackHandler(ALIAS_TRAVERSAL_JS_INTERFACE); |
91 | mContentViewCore.addJavascriptInterface(mCallback, ALIAS_TRAVERSAL_JS_INTERFACE); |
92 | } |
93 | } |
94 | |
95 | @Override |
96 | protected void removeAccessibilityApis() { |
97 | super.removeAccessibilityApis(); |
98 | |
99 | if (mCallback != null) { |
100 | mContentViewCore.removeJavascriptInterface(ALIAS_TRAVERSAL_JS_INTERFACE); |
101 | mCallback = null; |
102 | } |
103 | } |
104 | |
105 | /** |
106 | * Packs an accessibility action into a JSON object and sends it to AndroidVox. |
107 | * |
108 | * @param action The action identifier. |
109 | * @param arguments The action arguments, if applicable. |
110 | * @return The result of the action. |
111 | */ |
112 | private boolean sendActionToAndroidVox(int action, Bundle arguments) { |
113 | if (mCallback == null) return false; |
114 | if (mAccessibilityJSONObject == null) { |
115 | mAccessibilityJSONObject = new JSONObject(); |
116 | } else { |
117 | // Remove all keys from the object. |
118 | final Iterator<?> keys = mAccessibilityJSONObject.keys(); |
119 | while (keys.hasNext()) { |
120 | keys.next(); |
121 | keys.remove(); |
122 | } |
123 | } |
124 | |
125 | try { |
126 | mAccessibilityJSONObject.accumulate("action", action); |
127 | if (arguments != null) { |
128 | if (action == AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY || |
129 | action == AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY) { |
130 | final int granularity = arguments.getInt(AccessibilityNodeInfo. |
131 | ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT); |
132 | mAccessibilityJSONObject.accumulate("granularity", granularity); |
133 | } else if (action == AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT || |
134 | action == AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT) { |
135 | final String element = arguments.getString( |
136 | AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING); |
137 | mAccessibilityJSONObject.accumulate("element", element); |
138 | } |
139 | } |
140 | } catch (JSONException ex) { |
141 | return false; |
142 | } |
143 | |
144 | final String jsonString = mAccessibilityJSONObject.toString(); |
145 | final String jsCode = String.format(ACCESSIBILITY_ANDROIDVOX_TEMPLATE, jsonString); |
146 | return mCallback.performAction(mContentViewCore, jsCode); |
147 | } |
148 | |
149 | private static class CallbackHandler { |
150 | private static final String JAVASCRIPT_ACTION_TEMPLATE = |
151 | "(function() {" + |
152 | " retVal = false;" + |
153 | " try {" + |
154 | " retVal = %s;" + |
155 | " } catch (e) {" + |
156 | " retVal = false;" + |
157 | " }" + |
158 | " %s.onResult(%d, retVal);" + |
159 | "})()"; |
160 | |
161 | // Time in milliseconds to wait for a result before failing. |
162 | private static final long RESULT_TIMEOUT = 5000; |
163 | |
164 | private final AtomicInteger mResultIdCounter = new AtomicInteger(); |
165 | private final Object mResultLock = new Object(); |
166 | private final String mInterfaceName; |
167 | |
168 | private boolean mResult = false; |
169 | private long mResultId = -1; |
170 | |
171 | private CallbackHandler(String interfaceName) { |
172 | mInterfaceName = interfaceName; |
173 | } |
174 | |
175 | /** |
176 | * Performs an action and attempts to wait for a result. |
177 | * |
178 | * @param contentView The ContentViewCore to perform the action on. |
179 | * @param code Javascript code that evaluates to a result. |
180 | * @return The result of the action. |
181 | */ |
182 | private boolean performAction(ContentViewCore contentView, String code) { |
183 | final int resultId = mResultIdCounter.getAndIncrement(); |
184 | final String js = String.format(JAVASCRIPT_ACTION_TEMPLATE, code, mInterfaceName, |
185 | resultId); |
186 | contentView.evaluateJavaScript(js, null); |
187 | |
188 | return getResultAndClear(resultId); |
189 | } |
190 | |
191 | /** |
192 | * Gets the result of a request to perform an accessibility action. |
193 | * |
194 | * @param resultId The result id to match the result with the request. |
195 | * @return The result of the request. |
196 | */ |
197 | private boolean getResultAndClear(int resultId) { |
198 | synchronized (mResultLock) { |
199 | final boolean success = waitForResultTimedLocked(resultId); |
200 | final boolean result = success ? mResult : false; |
201 | clearResultLocked(); |
202 | return result; |
203 | } |
204 | } |
205 | |
206 | /** |
207 | * Clears the result state. |
208 | */ |
209 | private void clearResultLocked() { |
210 | mResultId = -1; |
211 | mResult = false; |
212 | } |
213 | |
214 | /** |
215 | * Waits up to a given bound for a result of a request and returns it. |
216 | * |
217 | * @param resultId The result id to match the result with the request. |
218 | * @return Whether the result was received. |
219 | */ |
220 | private boolean waitForResultTimedLocked(int resultId) { |
221 | long waitTimeMillis = RESULT_TIMEOUT; |
222 | final long startTimeMillis = SystemClock.uptimeMillis(); |
223 | while (true) { |
224 | try { |
225 | if (mResultId == resultId) return true; |
226 | if (mResultId > resultId) return false; |
227 | final long elapsedTimeMillis = SystemClock.uptimeMillis() - startTimeMillis; |
228 | waitTimeMillis = RESULT_TIMEOUT - elapsedTimeMillis; |
229 | if (waitTimeMillis <= 0) return false; |
230 | mResultLock.wait(waitTimeMillis); |
231 | } catch (InterruptedException ie) { |
232 | /* ignore */ |
233 | } |
234 | } |
235 | } |
236 | |
237 | /** |
238 | * Callback exposed to JavaScript. Handles returning the result of a |
239 | * request to a waiting (or potentially timed out) thread. |
240 | * |
241 | * @param id The result id of the request as a {@link String}. |
242 | * @param result The result of a request as a {@link String}. |
243 | */ |
244 | @JavascriptInterface |
245 | @SuppressWarnings("unused") |
246 | public void onResult(String id, String result) { |
247 | final long resultId; |
248 | try { |
249 | resultId = Long.parseLong(id); |
250 | } catch (NumberFormatException e) { |
251 | return; |
252 | } |
253 | |
254 | synchronized (mResultLock) { |
255 | if (resultId > mResultId) { |
256 | mResult = Boolean.parseBoolean(result); |
257 | mResultId = resultId; |
258 | } |
259 | mResultLock.notifyAll(); |
260 | } |
261 | } |
262 | } |
263 | } |