michael@0: /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- michael@0: * This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: package org.mozilla.gecko; michael@0: michael@0: import org.mozilla.gecko.gfx.LayerView; michael@0: import org.mozilla.gecko.util.ThreadUtils; michael@0: import org.mozilla.gecko.util.UiAsyncTask; michael@0: michael@0: import org.json.JSONArray; michael@0: import org.json.JSONException; michael@0: import org.json.JSONObject; michael@0: michael@0: import android.app.ActivityManager; michael@0: import android.app.ActivityManager.RunningServiceInfo; michael@0: import android.content.Context; michael@0: import android.graphics.Rect; michael@0: import android.os.Build; michael@0: import android.os.Bundle; michael@0: import android.util.Log; michael@0: import android.view.View; michael@0: import android.view.accessibility.AccessibilityEvent; michael@0: import android.view.accessibility.AccessibilityManager; michael@0: import android.view.accessibility.AccessibilityNodeInfo; michael@0: import android.view.accessibility.AccessibilityNodeProvider; michael@0: michael@0: import com.googlecode.eyesfree.braille.selfbraille.SelfBrailleClient; michael@0: import com.googlecode.eyesfree.braille.selfbraille.WriteData; michael@0: michael@0: import java.util.Arrays; michael@0: import java.util.HashSet; michael@0: import java.util.List; michael@0: michael@0: public class GeckoAccessibility { michael@0: private static final String LOGTAG = "GeckoAccessibility"; michael@0: private static final int VIRTUAL_CURSOR_PREVIOUS = 1; michael@0: private static final int VIRTUAL_CURSOR_POSITION = 2; michael@0: private static final int VIRTUAL_CURSOR_NEXT = 3; michael@0: michael@0: private static boolean sEnabled = false; michael@0: // Used to store the JSON message and populate the event later in the code path. michael@0: private static JSONObject sEventMessage = null; michael@0: private static AccessibilityNodeInfo sVirtualCursorNode = null; michael@0: michael@0: // This is the number Brailleback uses to start indexing routing keys. michael@0: private static final int BRAILLE_CLICK_BASE_INDEX = -275000000; michael@0: private static SelfBrailleClient sSelfBrailleClient = null; michael@0: michael@0: private static final HashSet sServiceWhitelist = michael@0: new HashSet(Arrays.asList(new String[] { michael@0: "com.google.android.marvin.talkback.TalkBackService", // Google Talkback screen reader michael@0: "com.mot.readout.ScreenReader", // Motorola screen reader michael@0: "info.spielproject.spiel.SpielService", // Spiel screen reader michael@0: "es.codefactory.android.app.ma.MAAccessibilityService" // Codefactory Mobile Accessibility screen reader michael@0: })); michael@0: michael@0: public static void updateAccessibilitySettings (final GeckoApp app) { michael@0: new UiAsyncTask(ThreadUtils.getBackgroundHandler()) { michael@0: @Override michael@0: public Void doInBackground(Void... args) { michael@0: JSONObject ret = new JSONObject(); michael@0: sEnabled = false; michael@0: AccessibilityManager accessibilityManager = michael@0: (AccessibilityManager) app.getSystemService(Context.ACCESSIBILITY_SERVICE); michael@0: if (accessibilityManager.isEnabled()) { michael@0: ActivityManager activityManager = michael@0: (ActivityManager) app.getSystemService(Context.ACTIVITY_SERVICE); michael@0: List runningServices = activityManager.getRunningServices(Integer.MAX_VALUE); michael@0: michael@0: for (RunningServiceInfo runningServiceInfo : runningServices) { michael@0: sEnabled = sServiceWhitelist.contains(runningServiceInfo.service.getClassName()); michael@0: if (sEnabled) michael@0: break; michael@0: } michael@0: if (sEnabled && sSelfBrailleClient == null && michael@0: Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { michael@0: sSelfBrailleClient = new SelfBrailleClient(GeckoAppShell.getContext(), false); michael@0: } michael@0: } michael@0: michael@0: try { michael@0: ret.put("enabled", sEnabled); michael@0: } catch (Exception ex) { michael@0: Log.e(LOGTAG, "Error building JSON arguments for Accessibility:Settings:", ex); michael@0: } michael@0: michael@0: GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Accessibility:Settings", michael@0: ret.toString())); michael@0: return null; michael@0: } michael@0: michael@0: @Override michael@0: public void onPostExecute(Void args) { michael@0: // Disable the dynamic toolbar when enabling accessibility. michael@0: // These features tend not to interact well. michael@0: app.setAccessibilityEnabled(sEnabled); michael@0: } michael@0: }.execute(); michael@0: } michael@0: michael@0: private static void populateEventFromJSON (AccessibilityEvent event, JSONObject message) { michael@0: final JSONArray textArray = message.optJSONArray("text"); michael@0: if (textArray != null) { michael@0: for (int i = 0; i < textArray.length(); i++) michael@0: event.getText().add(textArray.optString(i)); michael@0: } michael@0: michael@0: event.setContentDescription(message.optString("description")); michael@0: event.setEnabled(message.optBoolean("enabled", true)); michael@0: event.setChecked(message.optBoolean("checked")); michael@0: event.setPassword(message.optBoolean("password")); michael@0: event.setAddedCount(message.optInt("addedCount", -1)); michael@0: event.setRemovedCount(message.optInt("removedCount", -1)); michael@0: event.setFromIndex(message.optInt("fromIndex", -1)); michael@0: event.setItemCount(message.optInt("itemCount", -1)); michael@0: event.setCurrentItemIndex(message.optInt("currentItemIndex", -1)); michael@0: event.setBeforeText(message.optString("beforeText")); michael@0: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { michael@0: event.setToIndex(message.optInt("toIndex", -1)); michael@0: event.setScrollable(message.optBoolean("scrollable")); michael@0: event.setScrollX(message.optInt("scrollX", -1)); michael@0: event.setScrollY(message.optInt("scrollY", -1)); michael@0: } michael@0: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) { michael@0: event.setMaxScrollX(message.optInt("maxScrollX", -1)); michael@0: event.setMaxScrollY(message.optInt("maxScrollY", -1)); michael@0: } michael@0: } michael@0: michael@0: private static void sendDirectAccessibilityEvent(int eventType, JSONObject message) { michael@0: final AccessibilityEvent accEvent = AccessibilityEvent.obtain(eventType); michael@0: accEvent.setClassName(GeckoAccessibility.class.getName()); michael@0: accEvent.setPackageName(GeckoAppShell.getContext().getPackageName()); michael@0: populateEventFromJSON(accEvent, message); michael@0: AccessibilityManager accessibilityManager = michael@0: (AccessibilityManager) GeckoAppShell.getContext().getSystemService(Context.ACCESSIBILITY_SERVICE); michael@0: try { michael@0: accessibilityManager.sendAccessibilityEvent(accEvent); michael@0: } catch (IllegalStateException e) { michael@0: // Accessibility is off. michael@0: } michael@0: } michael@0: michael@0: public static void sendAccessibilityEvent (final JSONObject message) { michael@0: if (!sEnabled) michael@0: return; michael@0: michael@0: final int eventType = message.optInt("eventType", -1); michael@0: if (eventType < 0) { michael@0: Log.e(LOGTAG, "No accessibility event type provided"); michael@0: return; michael@0: } michael@0: michael@0: if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) { michael@0: // Before Jelly Bean we send events directly from here while spoofing the source by setting michael@0: // the package and class name manually. michael@0: ThreadUtils.postToBackgroundThread(new Runnable() { michael@0: @Override michael@0: public void run() { michael@0: sendDirectAccessibilityEvent(eventType, message); michael@0: } michael@0: }); michael@0: } else { michael@0: // In Jelly Bean we populate an AccessibilityNodeInfo with the minimal amount of data to have michael@0: // it work with TalkBack. michael@0: final LayerView view = GeckoAppShell.getLayerView(); michael@0: if (view == null) michael@0: return; michael@0: michael@0: if (sVirtualCursorNode == null) michael@0: sVirtualCursorNode = AccessibilityNodeInfo.obtain(view, VIRTUAL_CURSOR_POSITION); michael@0: sVirtualCursorNode.setEnabled(message.optBoolean("enabled", true)); michael@0: sVirtualCursorNode.setClickable(message.optBoolean("clickable")); michael@0: sVirtualCursorNode.setCheckable(message.optBoolean("checkable")); michael@0: sVirtualCursorNode.setChecked(message.optBoolean("checked")); michael@0: sVirtualCursorNode.setPassword(message.optBoolean("password")); michael@0: michael@0: final JSONArray textArray = message.optJSONArray("text"); michael@0: StringBuilder sb = new StringBuilder(); michael@0: if (textArray != null && textArray.length() > 0) { michael@0: sb.append(textArray.optString(0)); michael@0: for (int i = 1; i < textArray.length(); i++) { michael@0: sb.append(" ").append(textArray.optString(i)); michael@0: } michael@0: } michael@0: sVirtualCursorNode.setText(sb.toString()); michael@0: sVirtualCursorNode.setContentDescription(message.optString("description")); michael@0: michael@0: JSONObject bounds = message.optJSONObject("bounds"); michael@0: if (bounds != null) { michael@0: Rect relativeBounds = new Rect(bounds.optInt("left"), bounds.optInt("top"), michael@0: bounds.optInt("right"), bounds.optInt("bottom")); michael@0: sVirtualCursorNode.setBoundsInParent(relativeBounds); michael@0: int[] locationOnScreen = new int[2]; michael@0: view.getLocationOnScreen(locationOnScreen); michael@0: Rect screenBounds = new Rect(relativeBounds); michael@0: screenBounds.offset(locationOnScreen[0], locationOnScreen[1]); michael@0: sVirtualCursorNode.setBoundsInScreen(screenBounds); michael@0: } michael@0: michael@0: final JSONObject braille = message.optJSONObject("brailleOutput"); michael@0: if (braille != null) { michael@0: sendBrailleText(view, braille.optString("text"), michael@0: braille.optInt("selectionStart"), braille.optInt("selectionEnd")); michael@0: } michael@0: michael@0: ThreadUtils.postToUiThread(new Runnable() { michael@0: @Override michael@0: public void run() { michael@0: // If this is an accessibility focus, a lot of internal voodoo happens so we perform an michael@0: // accessibility focus action on the view, and it in turn sends the right events. michael@0: switch (eventType) { michael@0: case AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED: michael@0: sEventMessage = message; michael@0: view.performAccessibilityAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, null); michael@0: break; michael@0: case AccessibilityEvent.TYPE_ANNOUNCEMENT: michael@0: case AccessibilityEvent.TYPE_VIEW_SCROLLED: michael@0: sEventMessage = null; michael@0: final AccessibilityEvent accEvent = AccessibilityEvent.obtain(eventType); michael@0: view.onInitializeAccessibilityEvent(accEvent); michael@0: populateEventFromJSON(accEvent, message); michael@0: view.getParent().requestSendAccessibilityEvent(view, accEvent); michael@0: break; michael@0: default: michael@0: sEventMessage = message; michael@0: view.sendAccessibilityEvent(eventType); michael@0: break; michael@0: } michael@0: } michael@0: }); michael@0: michael@0: } michael@0: } michael@0: michael@0: private static void sendBrailleText(final View view, final String text, final int selectionStart, final int selectionEnd) { michael@0: AccessibilityNodeInfo info = AccessibilityNodeInfo.obtain(view, VIRTUAL_CURSOR_POSITION); michael@0: WriteData data = WriteData.forInfo(info); michael@0: data.setText(text); michael@0: // Set either the focus blink or the current caret position/selection michael@0: data.setSelectionStart(selectionStart); michael@0: data.setSelectionEnd(selectionEnd); michael@0: sSelfBrailleClient.write(data); michael@0: } michael@0: michael@0: public static void setDelegate(LayerView layerview) { michael@0: // Only use this delegate in Jelly Bean. michael@0: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { michael@0: layerview.setAccessibilityDelegate(new GeckoAccessibilityDelegate()); michael@0: layerview.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); michael@0: } michael@0: } michael@0: michael@0: public static void onLayerViewFocusChanged(LayerView layerview, boolean gainFocus) { michael@0: if (sEnabled) michael@0: GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Accessibility:Focus", michael@0: gainFocus ? "true" : "false")); michael@0: } michael@0: michael@0: public static class GeckoAccessibilityDelegate extends View.AccessibilityDelegate { michael@0: AccessibilityNodeProvider mAccessibilityNodeProvider; michael@0: michael@0: @Override michael@0: public void onPopulateAccessibilityEvent (View host, AccessibilityEvent event) { michael@0: super.onPopulateAccessibilityEvent(host, event); michael@0: if (sEventMessage != null) { michael@0: populateEventFromJSON(event, sEventMessage); michael@0: // No matter where the a11y focus is requested, we always force it back to the current vc position. michael@0: event.setSource(host, VIRTUAL_CURSOR_POSITION); michael@0: } michael@0: // We save the hover enter event so that we could reuse it for a subsequent accessibility focus event. michael@0: if (event.getEventType() != AccessibilityEvent.TYPE_VIEW_HOVER_ENTER) michael@0: sEventMessage = null; michael@0: } michael@0: michael@0: @Override michael@0: public AccessibilityNodeProvider getAccessibilityNodeProvider(final View host) { michael@0: if (mAccessibilityNodeProvider == null) michael@0: // The accessibility node structure for web content consists of 3 LayerView child nodes: michael@0: // 1. VIRTUAL_CURSOR_PREVIOUS: Represents the virtual cursor position that is previous to the michael@0: // current one. michael@0: // 2. VIRTUAL_CURSOR_POSITION: Represents the current position of the virtual cursor. michael@0: // 3. VIRTUAL_CURSOR_NEXT: Represents the next virtual cursor position. michael@0: mAccessibilityNodeProvider = new AccessibilityNodeProvider() { michael@0: @Override michael@0: public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualDescendantId) { michael@0: AccessibilityNodeInfo info = (virtualDescendantId == VIRTUAL_CURSOR_POSITION && sVirtualCursorNode != null) ? michael@0: AccessibilityNodeInfo.obtain(sVirtualCursorNode) : michael@0: AccessibilityNodeInfo.obtain(host, virtualDescendantId); michael@0: michael@0: switch (virtualDescendantId) { michael@0: case View.NO_ID: michael@0: // This is the parent LayerView node, populate it with children. michael@0: onInitializeAccessibilityNodeInfo(host, info); michael@0: info.addChild(host, VIRTUAL_CURSOR_PREVIOUS); michael@0: info.addChild(host, VIRTUAL_CURSOR_POSITION); michael@0: info.addChild(host, VIRTUAL_CURSOR_NEXT); michael@0: break; michael@0: default: michael@0: info.setParent(host); michael@0: info.setSource(host, virtualDescendantId); michael@0: info.setVisibleToUser(host.isShown()); michael@0: info.setPackageName(GeckoAppShell.getContext().getPackageName()); michael@0: info.setClassName(host.getClass().getName()); michael@0: info.addAction(AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS); michael@0: info.addAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS); michael@0: info.addAction(AccessibilityNodeInfo.ACTION_CLICK); michael@0: info.addAction(AccessibilityNodeInfo.ACTION_LONG_CLICK); michael@0: info.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY); michael@0: info.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY); michael@0: info.setMovementGranularities(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER | michael@0: AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD | michael@0: AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH); michael@0: break; michael@0: } michael@0: return info; michael@0: } michael@0: michael@0: @Override michael@0: public boolean performAction (int virtualViewId, int action, Bundle arguments) { michael@0: if (action == AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS) { michael@0: // The accessibility focus is permanently on the middle node, VIRTUAL_CURSOR_POSITION. michael@0: // When accessibility focus is requested on one of its siblings we move the virtual cursor michael@0: // either forward or backward depending on which sibling was selected. michael@0: michael@0: switch (virtualViewId) { michael@0: case VIRTUAL_CURSOR_PREVIOUS: michael@0: GeckoAppShell. michael@0: sendEventToGecko(GeckoEvent.createBroadcastEvent("Accessibility:PreviousObject", null)); michael@0: return true; michael@0: case VIRTUAL_CURSOR_NEXT: michael@0: GeckoAppShell. michael@0: sendEventToGecko(GeckoEvent.createBroadcastEvent("Accessibility:NextObject", null)); michael@0: return true; michael@0: default: michael@0: break; michael@0: } michael@0: } else if (action == AccessibilityNodeInfo.ACTION_CLICK && virtualViewId == VIRTUAL_CURSOR_POSITION) { michael@0: GeckoAppShell. michael@0: sendEventToGecko(GeckoEvent.createBroadcastEvent("Accessibility:ActivateObject", null)); michael@0: return true; michael@0: } else if (action == AccessibilityNodeInfo.ACTION_LONG_CLICK && virtualViewId == VIRTUAL_CURSOR_POSITION) { michael@0: GeckoAppShell. michael@0: sendEventToGecko(GeckoEvent.createBroadcastEvent("Accessibility:LongPress", null)); michael@0: return true; michael@0: } else if (action == AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY && michael@0: virtualViewId == VIRTUAL_CURSOR_POSITION) { michael@0: // XXX: Self brailling gives this action with a bogus argument instead of an actual click action; michael@0: // the argument value is the BRAILLE_CLICK_BASE_INDEX - the index of the routing key that was hit michael@0: int granularity = arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT); michael@0: if (granularity < 0) { michael@0: int keyIndex = BRAILLE_CLICK_BASE_INDEX - granularity; michael@0: JSONObject activationData = new JSONObject(); michael@0: try { michael@0: activationData.put("keyIndex", keyIndex); michael@0: } catch (JSONException e) { michael@0: return true; michael@0: } michael@0: GeckoAppShell. michael@0: sendEventToGecko(GeckoEvent.createBroadcastEvent("Accessibility:ActivateObject", activationData.toString())); michael@0: } else { michael@0: JSONObject movementData = new JSONObject(); michael@0: try { michael@0: movementData.put("direction", "Next"); michael@0: movementData.put("granularity", granularity); michael@0: } catch (JSONException e) { michael@0: return true; michael@0: } michael@0: GeckoAppShell. michael@0: sendEventToGecko(GeckoEvent.createBroadcastEvent("Accessibility:MoveByGranularity", movementData.toString())); michael@0: } michael@0: return true; michael@0: } else if (action == AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY && michael@0: virtualViewId == VIRTUAL_CURSOR_POSITION) { michael@0: JSONObject movementData = new JSONObject(); michael@0: try { michael@0: movementData.put("direction", "Previous"); michael@0: movementData.put("granularity", arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT)); michael@0: } catch (JSONException e) { michael@0: return true; michael@0: } michael@0: GeckoAppShell. michael@0: sendEventToGecko(GeckoEvent.createBroadcastEvent("Accessibility:MoveByGranularity", movementData.toString())); michael@0: return true; michael@0: } michael@0: return host.performAccessibilityAction(action, arguments); michael@0: } michael@0: }; michael@0: michael@0: return mAccessibilityNodeProvider; michael@0: } michael@0: } michael@0: }