|
1 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- |
|
2 * This Source Code Form is subject to the terms of the Mozilla Public |
|
3 * License, v. 2.0. If a copy of the MPL was not distributed with this |
|
4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
|
5 |
|
6 package org.mozilla.gecko; |
|
7 |
|
8 import org.mozilla.gecko.gfx.LayerView; |
|
9 import org.mozilla.gecko.util.ThreadUtils; |
|
10 import org.mozilla.gecko.util.UiAsyncTask; |
|
11 |
|
12 import org.json.JSONArray; |
|
13 import org.json.JSONException; |
|
14 import org.json.JSONObject; |
|
15 |
|
16 import android.app.ActivityManager; |
|
17 import android.app.ActivityManager.RunningServiceInfo; |
|
18 import android.content.Context; |
|
19 import android.graphics.Rect; |
|
20 import android.os.Build; |
|
21 import android.os.Bundle; |
|
22 import android.util.Log; |
|
23 import android.view.View; |
|
24 import android.view.accessibility.AccessibilityEvent; |
|
25 import android.view.accessibility.AccessibilityManager; |
|
26 import android.view.accessibility.AccessibilityNodeInfo; |
|
27 import android.view.accessibility.AccessibilityNodeProvider; |
|
28 |
|
29 import com.googlecode.eyesfree.braille.selfbraille.SelfBrailleClient; |
|
30 import com.googlecode.eyesfree.braille.selfbraille.WriteData; |
|
31 |
|
32 import java.util.Arrays; |
|
33 import java.util.HashSet; |
|
34 import java.util.List; |
|
35 |
|
36 public class GeckoAccessibility { |
|
37 private static final String LOGTAG = "GeckoAccessibility"; |
|
38 private static final int VIRTUAL_CURSOR_PREVIOUS = 1; |
|
39 private static final int VIRTUAL_CURSOR_POSITION = 2; |
|
40 private static final int VIRTUAL_CURSOR_NEXT = 3; |
|
41 |
|
42 private static boolean sEnabled = false; |
|
43 // Used to store the JSON message and populate the event later in the code path. |
|
44 private static JSONObject sEventMessage = null; |
|
45 private static AccessibilityNodeInfo sVirtualCursorNode = null; |
|
46 |
|
47 // This is the number Brailleback uses to start indexing routing keys. |
|
48 private static final int BRAILLE_CLICK_BASE_INDEX = -275000000; |
|
49 private static SelfBrailleClient sSelfBrailleClient = null; |
|
50 |
|
51 private static final HashSet<String> sServiceWhitelist = |
|
52 new HashSet<String>(Arrays.asList(new String[] { |
|
53 "com.google.android.marvin.talkback.TalkBackService", // Google Talkback screen reader |
|
54 "com.mot.readout.ScreenReader", // Motorola screen reader |
|
55 "info.spielproject.spiel.SpielService", // Spiel screen reader |
|
56 "es.codefactory.android.app.ma.MAAccessibilityService" // Codefactory Mobile Accessibility screen reader |
|
57 })); |
|
58 |
|
59 public static void updateAccessibilitySettings (final GeckoApp app) { |
|
60 new UiAsyncTask<Void, Void, Void>(ThreadUtils.getBackgroundHandler()) { |
|
61 @Override |
|
62 public Void doInBackground(Void... args) { |
|
63 JSONObject ret = new JSONObject(); |
|
64 sEnabled = false; |
|
65 AccessibilityManager accessibilityManager = |
|
66 (AccessibilityManager) app.getSystemService(Context.ACCESSIBILITY_SERVICE); |
|
67 if (accessibilityManager.isEnabled()) { |
|
68 ActivityManager activityManager = |
|
69 (ActivityManager) app.getSystemService(Context.ACTIVITY_SERVICE); |
|
70 List<RunningServiceInfo> runningServices = activityManager.getRunningServices(Integer.MAX_VALUE); |
|
71 |
|
72 for (RunningServiceInfo runningServiceInfo : runningServices) { |
|
73 sEnabled = sServiceWhitelist.contains(runningServiceInfo.service.getClassName()); |
|
74 if (sEnabled) |
|
75 break; |
|
76 } |
|
77 if (sEnabled && sSelfBrailleClient == null && |
|
78 Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { |
|
79 sSelfBrailleClient = new SelfBrailleClient(GeckoAppShell.getContext(), false); |
|
80 } |
|
81 } |
|
82 |
|
83 try { |
|
84 ret.put("enabled", sEnabled); |
|
85 } catch (Exception ex) { |
|
86 Log.e(LOGTAG, "Error building JSON arguments for Accessibility:Settings:", ex); |
|
87 } |
|
88 |
|
89 GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Accessibility:Settings", |
|
90 ret.toString())); |
|
91 return null; |
|
92 } |
|
93 |
|
94 @Override |
|
95 public void onPostExecute(Void args) { |
|
96 // Disable the dynamic toolbar when enabling accessibility. |
|
97 // These features tend not to interact well. |
|
98 app.setAccessibilityEnabled(sEnabled); |
|
99 } |
|
100 }.execute(); |
|
101 } |
|
102 |
|
103 private static void populateEventFromJSON (AccessibilityEvent event, JSONObject message) { |
|
104 final JSONArray textArray = message.optJSONArray("text"); |
|
105 if (textArray != null) { |
|
106 for (int i = 0; i < textArray.length(); i++) |
|
107 event.getText().add(textArray.optString(i)); |
|
108 } |
|
109 |
|
110 event.setContentDescription(message.optString("description")); |
|
111 event.setEnabled(message.optBoolean("enabled", true)); |
|
112 event.setChecked(message.optBoolean("checked")); |
|
113 event.setPassword(message.optBoolean("password")); |
|
114 event.setAddedCount(message.optInt("addedCount", -1)); |
|
115 event.setRemovedCount(message.optInt("removedCount", -1)); |
|
116 event.setFromIndex(message.optInt("fromIndex", -1)); |
|
117 event.setItemCount(message.optInt("itemCount", -1)); |
|
118 event.setCurrentItemIndex(message.optInt("currentItemIndex", -1)); |
|
119 event.setBeforeText(message.optString("beforeText")); |
|
120 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { |
|
121 event.setToIndex(message.optInt("toIndex", -1)); |
|
122 event.setScrollable(message.optBoolean("scrollable")); |
|
123 event.setScrollX(message.optInt("scrollX", -1)); |
|
124 event.setScrollY(message.optInt("scrollY", -1)); |
|
125 } |
|
126 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) { |
|
127 event.setMaxScrollX(message.optInt("maxScrollX", -1)); |
|
128 event.setMaxScrollY(message.optInt("maxScrollY", -1)); |
|
129 } |
|
130 } |
|
131 |
|
132 private static void sendDirectAccessibilityEvent(int eventType, JSONObject message) { |
|
133 final AccessibilityEvent accEvent = AccessibilityEvent.obtain(eventType); |
|
134 accEvent.setClassName(GeckoAccessibility.class.getName()); |
|
135 accEvent.setPackageName(GeckoAppShell.getContext().getPackageName()); |
|
136 populateEventFromJSON(accEvent, message); |
|
137 AccessibilityManager accessibilityManager = |
|
138 (AccessibilityManager) GeckoAppShell.getContext().getSystemService(Context.ACCESSIBILITY_SERVICE); |
|
139 try { |
|
140 accessibilityManager.sendAccessibilityEvent(accEvent); |
|
141 } catch (IllegalStateException e) { |
|
142 // Accessibility is off. |
|
143 } |
|
144 } |
|
145 |
|
146 public static void sendAccessibilityEvent (final JSONObject message) { |
|
147 if (!sEnabled) |
|
148 return; |
|
149 |
|
150 final int eventType = message.optInt("eventType", -1); |
|
151 if (eventType < 0) { |
|
152 Log.e(LOGTAG, "No accessibility event type provided"); |
|
153 return; |
|
154 } |
|
155 |
|
156 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) { |
|
157 // Before Jelly Bean we send events directly from here while spoofing the source by setting |
|
158 // the package and class name manually. |
|
159 ThreadUtils.postToBackgroundThread(new Runnable() { |
|
160 @Override |
|
161 public void run() { |
|
162 sendDirectAccessibilityEvent(eventType, message); |
|
163 } |
|
164 }); |
|
165 } else { |
|
166 // In Jelly Bean we populate an AccessibilityNodeInfo with the minimal amount of data to have |
|
167 // it work with TalkBack. |
|
168 final LayerView view = GeckoAppShell.getLayerView(); |
|
169 if (view == null) |
|
170 return; |
|
171 |
|
172 if (sVirtualCursorNode == null) |
|
173 sVirtualCursorNode = AccessibilityNodeInfo.obtain(view, VIRTUAL_CURSOR_POSITION); |
|
174 sVirtualCursorNode.setEnabled(message.optBoolean("enabled", true)); |
|
175 sVirtualCursorNode.setClickable(message.optBoolean("clickable")); |
|
176 sVirtualCursorNode.setCheckable(message.optBoolean("checkable")); |
|
177 sVirtualCursorNode.setChecked(message.optBoolean("checked")); |
|
178 sVirtualCursorNode.setPassword(message.optBoolean("password")); |
|
179 |
|
180 final JSONArray textArray = message.optJSONArray("text"); |
|
181 StringBuilder sb = new StringBuilder(); |
|
182 if (textArray != null && textArray.length() > 0) { |
|
183 sb.append(textArray.optString(0)); |
|
184 for (int i = 1; i < textArray.length(); i++) { |
|
185 sb.append(" ").append(textArray.optString(i)); |
|
186 } |
|
187 } |
|
188 sVirtualCursorNode.setText(sb.toString()); |
|
189 sVirtualCursorNode.setContentDescription(message.optString("description")); |
|
190 |
|
191 JSONObject bounds = message.optJSONObject("bounds"); |
|
192 if (bounds != null) { |
|
193 Rect relativeBounds = new Rect(bounds.optInt("left"), bounds.optInt("top"), |
|
194 bounds.optInt("right"), bounds.optInt("bottom")); |
|
195 sVirtualCursorNode.setBoundsInParent(relativeBounds); |
|
196 int[] locationOnScreen = new int[2]; |
|
197 view.getLocationOnScreen(locationOnScreen); |
|
198 Rect screenBounds = new Rect(relativeBounds); |
|
199 screenBounds.offset(locationOnScreen[0], locationOnScreen[1]); |
|
200 sVirtualCursorNode.setBoundsInScreen(screenBounds); |
|
201 } |
|
202 |
|
203 final JSONObject braille = message.optJSONObject("brailleOutput"); |
|
204 if (braille != null) { |
|
205 sendBrailleText(view, braille.optString("text"), |
|
206 braille.optInt("selectionStart"), braille.optInt("selectionEnd")); |
|
207 } |
|
208 |
|
209 ThreadUtils.postToUiThread(new Runnable() { |
|
210 @Override |
|
211 public void run() { |
|
212 // If this is an accessibility focus, a lot of internal voodoo happens so we perform an |
|
213 // accessibility focus action on the view, and it in turn sends the right events. |
|
214 switch (eventType) { |
|
215 case AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED: |
|
216 sEventMessage = message; |
|
217 view.performAccessibilityAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, null); |
|
218 break; |
|
219 case AccessibilityEvent.TYPE_ANNOUNCEMENT: |
|
220 case AccessibilityEvent.TYPE_VIEW_SCROLLED: |
|
221 sEventMessage = null; |
|
222 final AccessibilityEvent accEvent = AccessibilityEvent.obtain(eventType); |
|
223 view.onInitializeAccessibilityEvent(accEvent); |
|
224 populateEventFromJSON(accEvent, message); |
|
225 view.getParent().requestSendAccessibilityEvent(view, accEvent); |
|
226 break; |
|
227 default: |
|
228 sEventMessage = message; |
|
229 view.sendAccessibilityEvent(eventType); |
|
230 break; |
|
231 } |
|
232 } |
|
233 }); |
|
234 |
|
235 } |
|
236 } |
|
237 |
|
238 private static void sendBrailleText(final View view, final String text, final int selectionStart, final int selectionEnd) { |
|
239 AccessibilityNodeInfo info = AccessibilityNodeInfo.obtain(view, VIRTUAL_CURSOR_POSITION); |
|
240 WriteData data = WriteData.forInfo(info); |
|
241 data.setText(text); |
|
242 // Set either the focus blink or the current caret position/selection |
|
243 data.setSelectionStart(selectionStart); |
|
244 data.setSelectionEnd(selectionEnd); |
|
245 sSelfBrailleClient.write(data); |
|
246 } |
|
247 |
|
248 public static void setDelegate(LayerView layerview) { |
|
249 // Only use this delegate in Jelly Bean. |
|
250 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { |
|
251 layerview.setAccessibilityDelegate(new GeckoAccessibilityDelegate()); |
|
252 layerview.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); |
|
253 } |
|
254 } |
|
255 |
|
256 public static void onLayerViewFocusChanged(LayerView layerview, boolean gainFocus) { |
|
257 if (sEnabled) |
|
258 GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Accessibility:Focus", |
|
259 gainFocus ? "true" : "false")); |
|
260 } |
|
261 |
|
262 public static class GeckoAccessibilityDelegate extends View.AccessibilityDelegate { |
|
263 AccessibilityNodeProvider mAccessibilityNodeProvider; |
|
264 |
|
265 @Override |
|
266 public void onPopulateAccessibilityEvent (View host, AccessibilityEvent event) { |
|
267 super.onPopulateAccessibilityEvent(host, event); |
|
268 if (sEventMessage != null) { |
|
269 populateEventFromJSON(event, sEventMessage); |
|
270 // No matter where the a11y focus is requested, we always force it back to the current vc position. |
|
271 event.setSource(host, VIRTUAL_CURSOR_POSITION); |
|
272 } |
|
273 // We save the hover enter event so that we could reuse it for a subsequent accessibility focus event. |
|
274 if (event.getEventType() != AccessibilityEvent.TYPE_VIEW_HOVER_ENTER) |
|
275 sEventMessage = null; |
|
276 } |
|
277 |
|
278 @Override |
|
279 public AccessibilityNodeProvider getAccessibilityNodeProvider(final View host) { |
|
280 if (mAccessibilityNodeProvider == null) |
|
281 // The accessibility node structure for web content consists of 3 LayerView child nodes: |
|
282 // 1. VIRTUAL_CURSOR_PREVIOUS: Represents the virtual cursor position that is previous to the |
|
283 // current one. |
|
284 // 2. VIRTUAL_CURSOR_POSITION: Represents the current position of the virtual cursor. |
|
285 // 3. VIRTUAL_CURSOR_NEXT: Represents the next virtual cursor position. |
|
286 mAccessibilityNodeProvider = new AccessibilityNodeProvider() { |
|
287 @Override |
|
288 public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualDescendantId) { |
|
289 AccessibilityNodeInfo info = (virtualDescendantId == VIRTUAL_CURSOR_POSITION && sVirtualCursorNode != null) ? |
|
290 AccessibilityNodeInfo.obtain(sVirtualCursorNode) : |
|
291 AccessibilityNodeInfo.obtain(host, virtualDescendantId); |
|
292 |
|
293 switch (virtualDescendantId) { |
|
294 case View.NO_ID: |
|
295 // This is the parent LayerView node, populate it with children. |
|
296 onInitializeAccessibilityNodeInfo(host, info); |
|
297 info.addChild(host, VIRTUAL_CURSOR_PREVIOUS); |
|
298 info.addChild(host, VIRTUAL_CURSOR_POSITION); |
|
299 info.addChild(host, VIRTUAL_CURSOR_NEXT); |
|
300 break; |
|
301 default: |
|
302 info.setParent(host); |
|
303 info.setSource(host, virtualDescendantId); |
|
304 info.setVisibleToUser(host.isShown()); |
|
305 info.setPackageName(GeckoAppShell.getContext().getPackageName()); |
|
306 info.setClassName(host.getClass().getName()); |
|
307 info.addAction(AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS); |
|
308 info.addAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS); |
|
309 info.addAction(AccessibilityNodeInfo.ACTION_CLICK); |
|
310 info.addAction(AccessibilityNodeInfo.ACTION_LONG_CLICK); |
|
311 info.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY); |
|
312 info.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY); |
|
313 info.setMovementGranularities(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER | |
|
314 AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD | |
|
315 AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH); |
|
316 break; |
|
317 } |
|
318 return info; |
|
319 } |
|
320 |
|
321 @Override |
|
322 public boolean performAction (int virtualViewId, int action, Bundle arguments) { |
|
323 if (action == AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS) { |
|
324 // The accessibility focus is permanently on the middle node, VIRTUAL_CURSOR_POSITION. |
|
325 // When accessibility focus is requested on one of its siblings we move the virtual cursor |
|
326 // either forward or backward depending on which sibling was selected. |
|
327 |
|
328 switch (virtualViewId) { |
|
329 case VIRTUAL_CURSOR_PREVIOUS: |
|
330 GeckoAppShell. |
|
331 sendEventToGecko(GeckoEvent.createBroadcastEvent("Accessibility:PreviousObject", null)); |
|
332 return true; |
|
333 case VIRTUAL_CURSOR_NEXT: |
|
334 GeckoAppShell. |
|
335 sendEventToGecko(GeckoEvent.createBroadcastEvent("Accessibility:NextObject", null)); |
|
336 return true; |
|
337 default: |
|
338 break; |
|
339 } |
|
340 } else if (action == AccessibilityNodeInfo.ACTION_CLICK && virtualViewId == VIRTUAL_CURSOR_POSITION) { |
|
341 GeckoAppShell. |
|
342 sendEventToGecko(GeckoEvent.createBroadcastEvent("Accessibility:ActivateObject", null)); |
|
343 return true; |
|
344 } else if (action == AccessibilityNodeInfo.ACTION_LONG_CLICK && virtualViewId == VIRTUAL_CURSOR_POSITION) { |
|
345 GeckoAppShell. |
|
346 sendEventToGecko(GeckoEvent.createBroadcastEvent("Accessibility:LongPress", null)); |
|
347 return true; |
|
348 } else if (action == AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY && |
|
349 virtualViewId == VIRTUAL_CURSOR_POSITION) { |
|
350 // XXX: Self brailling gives this action with a bogus argument instead of an actual click action; |
|
351 // the argument value is the BRAILLE_CLICK_BASE_INDEX - the index of the routing key that was hit |
|
352 int granularity = arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT); |
|
353 if (granularity < 0) { |
|
354 int keyIndex = BRAILLE_CLICK_BASE_INDEX - granularity; |
|
355 JSONObject activationData = new JSONObject(); |
|
356 try { |
|
357 activationData.put("keyIndex", keyIndex); |
|
358 } catch (JSONException e) { |
|
359 return true; |
|
360 } |
|
361 GeckoAppShell. |
|
362 sendEventToGecko(GeckoEvent.createBroadcastEvent("Accessibility:ActivateObject", activationData.toString())); |
|
363 } else { |
|
364 JSONObject movementData = new JSONObject(); |
|
365 try { |
|
366 movementData.put("direction", "Next"); |
|
367 movementData.put("granularity", granularity); |
|
368 } catch (JSONException e) { |
|
369 return true; |
|
370 } |
|
371 GeckoAppShell. |
|
372 sendEventToGecko(GeckoEvent.createBroadcastEvent("Accessibility:MoveByGranularity", movementData.toString())); |
|
373 } |
|
374 return true; |
|
375 } else if (action == AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY && |
|
376 virtualViewId == VIRTUAL_CURSOR_POSITION) { |
|
377 JSONObject movementData = new JSONObject(); |
|
378 try { |
|
379 movementData.put("direction", "Previous"); |
|
380 movementData.put("granularity", arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT)); |
|
381 } catch (JSONException e) { |
|
382 return true; |
|
383 } |
|
384 GeckoAppShell. |
|
385 sendEventToGecko(GeckoEvent.createBroadcastEvent("Accessibility:MoveByGranularity", movementData.toString())); |
|
386 return true; |
|
387 } |
|
388 return host.performAccessibilityAction(action, arguments); |
|
389 } |
|
390 }; |
|
391 |
|
392 return mAccessibilityNodeProvider; |
|
393 } |
|
394 } |
|
395 } |