| 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 | } |