Added virtual view hierarchy for keyboard accessibility.

Bug: 5829051
Change-Id: Ied1b6267eec616bd3b9337f6e761b0c740aa0eb2
diff --git a/java/Android.mk b/java/Android.mk
index fd71d82..52cc18b 100644
--- a/java/Android.mk
+++ b/java/Android.mk
@@ -30,6 +30,7 @@
 
 LOCAL_STATIC_JAVA_LIBRARIES := android-common
 LOCAL_STATIC_JAVA_LIBRARIES += inputmethod-common
+LOCAL_STATIC_JAVA_LIBRARIES += android-support-v4
 
 # Do not compress dictionary files to mmap dict data runtime
 LOCAL_AAPT_FLAGS := -0 .dict
diff --git a/java/src/com/android/inputmethod/accessibility/AccessibilityEntityProvider.java b/java/src/com/android/inputmethod/accessibility/AccessibilityEntityProvider.java
new file mode 100644
index 0000000..dc7c12b
--- /dev/null
+++ b/java/src/com/android/inputmethod/accessibility/AccessibilityEntityProvider.java
@@ -0,0 +1,377 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.inputmethod.accessibility;
+
+import android.graphics.Rect;
+import android.inputmethodservice.InputMethodService;
+import android.support.v4.view.ViewCompat;
+import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
+import android.support.v4.view.accessibility.AccessibilityNodeProviderCompat;
+import android.support.v4.view.accessibility.AccessibilityRecordCompat;
+import android.util.Log;
+import android.util.SparseArray;
+import android.view.View;
+import android.view.ViewTreeObserver.OnGlobalLayoutListener;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.inputmethod.EditorInfo;
+
+import com.android.inputmethod.keyboard.Key;
+import com.android.inputmethod.keyboard.Keyboard;
+import com.android.inputmethod.keyboard.KeyboardView;
+
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Exposes a virtual view sub-tree for {@link KeyboardView} and generates
+ * {@link AccessibilityEvent}s for individual {@link Key}s.
+ * <p>
+ * A virtual sub-tree is composed of imaginary {@link View}s that are reported
+ * as a part of the view hierarchy for accessibility purposes. This enables
+ * custom views that draw complex content to report them selves as a tree of
+ * virtual views, thus conveying their logical structure.
+ * </p>
+ */
+public class AccessibilityEntityProvider extends AccessibilityNodeProviderCompat {
+    private static final String TAG = AccessibilityEntityProvider.class.getSimpleName();
+
+    private final KeyboardView mKeyboardView;
+    private final InputMethodService mInputMethodService;
+    private final KeyCodeDescriptionMapper mKeyCodeDescriptionMapper;
+    private final AccessibilityUtils mAccessibilityUtils;
+
+    /** A map of integer IDs to {@link Key}s. */
+    private final SparseArray<Key> mVirtualViewIdToKey = new SparseArray<Key>();
+
+    /** Temporary rect used to calculate in-screen bounds. */
+    private final Rect mTempBoundsInScreen = new Rect();
+
+    /** The parent view's cached on-screen location. */
+    private final int[] mParentLocation = new int[2];
+
+    public AccessibilityEntityProvider(KeyboardView keyboardView, InputMethodService inputMethod) {
+        mKeyboardView = keyboardView;
+        mInputMethodService = inputMethod;
+
+        mKeyCodeDescriptionMapper = KeyCodeDescriptionMapper.getInstance();
+        mAccessibilityUtils = AccessibilityUtils.getInstance();
+
+        assignVirtualViewIds();
+        updateParentLocation();
+
+        // Ensure that the on-screen bounds are cleared when the layout changes.
+        mKeyboardView.getViewTreeObserver().addOnGlobalLayoutListener(mGlobalLayoutListener);
+    }
+
+    /**
+     * Creates and populates an {@link AccessibilityEvent} for the specified key
+     * and event type.
+     *
+     * @param key A key on the host keyboard view.
+     * @param eventType The event type to create.
+     * @return A populated {@link AccessibilityEvent} for the key.
+     * @see AccessibilityEvent
+     */
+    public AccessibilityEvent createAccessibilityEvent(Key key, int eventType) {
+        final int virtualViewId = generateVirtualViewIdForKey(key);
+        final String keyDescription = getKeyDescription(key);
+
+        final AccessibilityEvent event = AccessibilityEvent.obtain(eventType);
+        event.setPackageName(mKeyboardView.getContext().getPackageName());
+        event.setClassName(key.getClass().getName());
+        event.getText().add(keyDescription);
+
+        final AccessibilityRecordCompat record = new AccessibilityRecordCompat(event);
+        record.setSource(mKeyboardView, virtualViewId);
+
+        return event;
+    }
+
+    /**
+     * Returns an {@link AccessibilityNodeInfoCompat} representing a virtual
+     * view, i.e. a descendant of the host View, with the given <code>virtualViewId</code> or
+     * the host View itself if <code>virtualViewId</code> equals to {@link View#NO_ID}.
+     * <p>
+     * A virtual descendant is an imaginary View that is reported as a part of
+     * the view hierarchy for accessibility purposes. This enables custom views
+     * that draw complex content to report them selves as a tree of virtual
+     * views, thus conveying their logical structure.
+     * </p>
+     * <p>
+     * The implementer is responsible for obtaining an accessibility node info
+     * from the pool of reusable instances and setting the desired properties of
+     * the node info before returning it.
+     * </p>
+     *
+     * @param virtualViewId A client defined virtual view id.
+     * @return A populated {@link AccessibilityNodeInfoCompat} for a virtual
+     *         descendant or the host View.
+     * @see AccessibilityNodeInfoCompat
+     */
+    @Override
+    public AccessibilityNodeInfoCompat createAccessibilityNodeInfo(int virtualViewId) {
+        AccessibilityNodeInfoCompat info = null;
+
+        if (virtualViewId == View.NO_ID) {
+            // We are requested to create an AccessibilityNodeInfo describing
+            // this View, i.e. the root of the virtual sub-tree.
+            info = AccessibilityNodeInfoCompat.obtain(mKeyboardView);
+            ViewCompat.onInitializeAccessibilityNodeInfo(mKeyboardView, info);
+
+            // Add the virtual children of the root View.
+            // TODO(alanv): Need to assign a unique ID to each key.
+            final Keyboard keyboard = mKeyboardView.getKeyboard();
+            final Set<Key> keys = keyboard.mKeys;
+            for (Key key : keys) {
+                final int childVirtualViewId = generateVirtualViewIdForKey(key);
+                info.addChild(mKeyboardView, childVirtualViewId);
+            }
+        } else {
+            // Find the view that corresponds to the given id.
+            final Key key = mVirtualViewIdToKey.get(virtualViewId);
+            if (key == null) {
+                Log.e(TAG, "Invalid virtual view ID: " + virtualViewId);
+                return null;
+            }
+
+            final String keyDescription = getKeyDescription(key);
+            final Rect boundsInParent = key.mHitBox;
+
+            // Calculate the key's in-screen bounds.
+            mTempBoundsInScreen.set(boundsInParent);
+            mTempBoundsInScreen.offset(mParentLocation[0], mParentLocation[1]);
+
+            final Rect boundsInScreen = mTempBoundsInScreen;
+
+            // Obtain and initialize an AccessibilityNodeInfo with
+            // information about the virtual view.
+            info = AccessibilityNodeInfoCompat.obtain();
+            info.addAction(AccessibilityNodeInfoCompat.ACTION_SELECT);
+            info.addAction(AccessibilityNodeInfoCompat.ACTION_CLEAR_SELECTION);
+            info.setPackageName(mKeyboardView.getContext().getPackageName());
+            info.setClassName(key.getClass().getName());
+            info.setBoundsInParent(boundsInParent);
+            info.setBoundsInScreen(boundsInScreen);
+            info.setParent(mKeyboardView);
+            info.setSource(mKeyboardView, virtualViewId);
+            info.setBoundsInScreen(boundsInScreen);
+            info.setText(keyDescription);
+        }
+
+        return info;
+    }
+
+    /**
+     * Performs an accessibility action on a virtual view, i.e. a descendant of
+     * the host View, with the given <code>virtualViewId</code> or the host View itself if
+     * <code>virtualViewId</code> equals to {@link View#NO_ID}.
+     *
+     * @param action The action to perform.
+     * @param virtualViewId A client defined virtual view id.
+     * @return True if the action was performed.
+     * @see #createAccessibilityNodeInfo(int)
+     * @see AccessibilityNodeInfoCompat
+     */
+    @Override
+    public boolean performAccessibilityAction(int action, int virtualViewId) {
+        if (virtualViewId == View.NO_ID) {
+            // Perform the action on the host View.
+            switch (action) {
+            case AccessibilityNodeInfoCompat.ACTION_SELECT:
+                if (!mKeyboardView.isSelected()) {
+                    mKeyboardView.setSelected(true);
+                    return mKeyboardView.isSelected();
+                }
+                break;
+            case AccessibilityNodeInfoCompat.ACTION_CLEAR_SELECTION:
+                if (mKeyboardView.isSelected()) {
+                    mKeyboardView.setSelected(false);
+                    return !mKeyboardView.isSelected();
+                }
+                break;
+            }
+        } else {
+            // Find the view that corresponds to the given id.
+            final Key child = mVirtualViewIdToKey.get(virtualViewId);
+            if (child == null)
+                return false;
+
+            // Perform the action on a virtual view.
+            switch (action) {
+            case AccessibilityNodeInfoCompat.ACTION_SELECT:
+                // TODO: Provide some focus indicator.
+                return true;
+            case AccessibilityNodeInfoCompat.ACTION_CLEAR_SELECTION:
+                // TODO: Provide some clear focus indicator.
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Finds {@link AccessibilityNodeInfoCompat}s by text. The match is case
+     * insensitive containment. The search is relative to the virtual view, i.e.
+     * a descendant of the host View, with the given <code>virtualViewId</code> or the host
+     * View itself <code>virtualViewId</code> equals to {@link View#NO_ID}.
+     *
+     * @param virtualViewId A client defined virtual view id which defined the
+     *            root of the tree in which to perform the search.
+     * @param text The searched text.
+     * @return A list of node info.
+     * @see #createAccessibilityNodeInfo(int)
+     * @see AccessibilityNodeInfoCompat
+     */
+    @Override
+    public List<AccessibilityNodeInfoCompat> findAccessibilityNodeInfosByText(
+            String text, int virtualViewId) {
+        final String searchedLowerCase = text.toLowerCase();
+        final Keyboard keyboard = mKeyboardView.getKeyboard();
+
+        List<AccessibilityNodeInfoCompat> results = null;
+
+        if (virtualViewId == View.NO_ID) {
+            for (Key key : keyboard.mKeys) {
+                results = findByTextAndPopulate(searchedLowerCase, key, results);
+            }
+        } else {
+            final Key key = mVirtualViewIdToKey.get(virtualViewId);
+
+            results = findByTextAndPopulate(searchedLowerCase, key, results);
+        }
+
+        if (results == null) {
+            return Collections.emptyList();
+        }
+
+        return results;
+    }
+
+    /**
+     * Helper method for {@link #findAccessibilityNodeInfosByText(String, int)}.
+     * Takes a current set of results and matches a specified key against a
+     * lower-case search string. Returns an updated list of results.
+     *
+     * @param searchedLowerCase The lower-case search string.
+     * @param key The key to compare against.
+     * @param results The current list of results, or {@code null} if no results
+     *            found.
+     * @return An updated list of results, or {@code null} if no results found.
+     */
+    private List<AccessibilityNodeInfoCompat> findByTextAndPopulate(String searchedLowerCase,
+            Key key, List<AccessibilityNodeInfoCompat> results) {
+        if (!keyContainsText(key, searchedLowerCase)) {
+            return results;
+        }
+
+        final int childVirtualViewId = generateVirtualViewIdForKey(key);
+        final AccessibilityNodeInfoCompat nodeInfo = createAccessibilityNodeInfo(
+                childVirtualViewId);
+
+        if (results == null) {
+            results = new LinkedList<AccessibilityNodeInfoCompat>();
+        }
+
+        results.add(nodeInfo);
+
+        return results;
+    }
+
+    /**
+     * Returns whether a key's current description contains the lower-case
+     * search text.
+     *
+     * @param key The key to compare against.
+     * @param textLowerCase The lower-case search string.
+     * @return {@code true} if the key contains the search text.
+     */
+    private boolean keyContainsText(Key key, String textLowerCase) {
+        if (key == null) {
+            return false;
+        }
+
+        final String description = getKeyDescription(key);
+
+        if (description == null) {
+            return false;
+        }
+
+        return description.toLowerCase().contains(textLowerCase);
+    }
+
+    /**
+     * Returns the context-specific description for a {@link Key}.
+     *
+     * @param key The key to describe.
+     * @return The context-specific description of the key.
+     */
+    private String getKeyDescription(Key key) {
+        final EditorInfo editorInfo = mInputMethodService.getCurrentInputEditorInfo();
+        final boolean shouldObscure = mAccessibilityUtils.shouldObscureInput(editorInfo);
+        final String keyDescription = mKeyCodeDescriptionMapper.getDescriptionForKey(
+                mKeyboardView.getContext(), mKeyboardView.getKeyboard(), key, shouldObscure);
+
+        return keyDescription;
+    }
+
+    /**
+     * Assigns virtual view IDs to keyboard keys and populates the related maps.
+     */
+    private void assignVirtualViewIds() {
+        final Keyboard keyboard = mKeyboardView.getKeyboard();
+        if (keyboard == null) {
+            return;
+        }
+
+        mVirtualViewIdToKey.clear();
+
+        final Set<Key> keySet = keyboard.mKeys;
+        for (Key key : keySet) {
+            final int virtualViewId = generateVirtualViewIdForKey(key);
+            mVirtualViewIdToKey.put(virtualViewId, key);
+        }
+    }
+
+    /**
+     * Updates the parent's on-screen location.
+     */
+    private void updateParentLocation() {
+        mKeyboardView.getLocationOnScreen(mParentLocation);
+    }
+
+    /**
+     * Generates a virtual view identifier for the specified key.
+     *
+     * @param key The key to identify.
+     * @return A virtual view identifier.
+     */
+    private static int generateVirtualViewIdForKey(Key key) {
+        // The key code is unique within an instance of a Keyboard.
+        return key.mCode;
+    }
+
+    private final OnGlobalLayoutListener mGlobalLayoutListener = new OnGlobalLayoutListener() {
+        @Override
+        public void onGlobalLayout() {
+            assignVirtualViewIds();
+            updateParentLocation();
+        }
+    };
+}
diff --git a/java/src/com/android/inputmethod/accessibility/AccessibilityUtils.java b/java/src/com/android/inputmethod/accessibility/AccessibilityUtils.java
index 9caed00..41da2aa 100644
--- a/java/src/com/android/inputmethod/accessibility/AccessibilityUtils.java
+++ b/java/src/com/android/inputmethod/accessibility/AccessibilityUtils.java
@@ -21,13 +21,14 @@
 import android.media.AudioManager;
 import android.os.SystemClock;
 import android.provider.Settings;
+import android.support.v4.view.MotionEventCompat;
 import android.util.Log;
 import android.view.MotionEvent;
 import android.view.accessibility.AccessibilityEvent;
 import android.view.accessibility.AccessibilityManager;
 import android.view.inputmethod.EditorInfo;
 
-import com.android.inputmethod.compat.AccessibilityManagerCompatWrapper;
+import com.android.inputmethod.compat.AccessibilityManagerCompatUtils;
 import com.android.inputmethod.compat.AudioManagerCompatWrapper;
 import com.android.inputmethod.compat.InputTypeCompatUtils;
 import com.android.inputmethod.compat.MotionEventCompatUtils;
@@ -44,7 +45,6 @@
 
     private Context mContext;
     private AccessibilityManager mAccessibilityManager;
-    private AccessibilityManagerCompatWrapper mCompatManager;
     private AudioManagerCompatWrapper mAudioManager;
 
     /*
@@ -77,7 +77,6 @@
         mContext = context;
         mAccessibilityManager = (AccessibilityManager) context
                 .getSystemService(Context.ACCESSIBILITY_SERVICE);
-        mCompatManager = new AccessibilityManagerCompatWrapper(mAccessibilityManager);
 
         final AudioManager audioManager = (AudioManager) context
                 .getSystemService(Context.AUDIO_SERVICE);
@@ -94,7 +93,7 @@
     public boolean isTouchExplorationEnabled() {
         return ENABLE_ACCESSIBILITY
                 && mAccessibilityManager.isEnabled()
-                && mCompatManager.isTouchExplorationEnabled();
+                && AccessibilityManagerCompatUtils.isTouchExplorationEnabled(mAccessibilityManager);
     }
 
     /**
@@ -110,13 +109,13 @@
 
         return action == MotionEventCompatUtils.ACTION_HOVER_ENTER
                 || action == MotionEventCompatUtils.ACTION_HOVER_EXIT
-                || action == MotionEventCompatUtils.ACTION_HOVER_MOVE;
+                || action == MotionEventCompat.ACTION_HOVER_MOVE;
     }
 
     /**
      * Returns whether the device should obscure typed password characters.
      * Typically this means speaking "dot" in place of non-control characters.
-     * 
+     *
      * @return {@code true} if the device should obscure password characters.
      */
     public boolean shouldObscureInput(EditorInfo editorInfo) {
diff --git a/java/src/com/android/inputmethod/accessibility/AccessibleKeyboardViewProxy.java b/java/src/com/android/inputmethod/accessibility/AccessibleKeyboardViewProxy.java
index 2294a18..2401d93 100644
--- a/java/src/com/android/inputmethod/accessibility/AccessibleKeyboardViewProxy.java
+++ b/java/src/com/android/inputmethod/accessibility/AccessibleKeyboardViewProxy.java
@@ -20,13 +20,16 @@
 import android.graphics.Color;
 import android.graphics.Paint;
 import android.inputmethodservice.InputMethodService;
-import android.util.Log;
+import android.support.v4.view.AccessibilityDelegateCompat;
+import android.support.v4.view.MotionEventCompat;
+import android.support.v4.view.ViewCompat;
+import android.support.v4.view.accessibility.AccessibilityEventCompat;
 import android.view.MotionEvent;
+import android.view.View;
 import android.view.accessibility.AccessibilityEvent;
-import android.view.inputmethod.EditorInfo;
 
-import com.android.inputmethod.compat.AccessibilityEventCompatUtils;
 import com.android.inputmethod.compat.MotionEventCompatUtils;
+import com.android.inputmethod.compat.ViewParentCompatUtils;
 import com.android.inputmethod.keyboard.Key;
 import com.android.inputmethod.keyboard.Keyboard;
 import com.android.inputmethod.keyboard.KeyboardId;
@@ -34,14 +37,14 @@
 import com.android.inputmethod.keyboard.PointerTracker;
 import com.android.inputmethod.latin.R;
 
-public class AccessibleKeyboardViewProxy {
-    private static final String TAG = AccessibleKeyboardViewProxy.class.getSimpleName();
+public class AccessibleKeyboardViewProxy extends AccessibilityDelegateCompat {
     private static final AccessibleKeyboardViewProxy sInstance = new AccessibleKeyboardViewProxy();
 
     private InputMethodService mInputMethod;
     private FlickGestureDetector mGestureDetector;
     private LatinKeyboardView mView;
     private AccessibleKeyboardActionListener mListener;
+    private AccessibilityEntityProvider mAccessibilityNodeProvider;
 
     private Key mLastHoverKey = null;
 
@@ -54,10 +57,6 @@
         return sInstance;
     }
 
-    public static void setView(LatinKeyboardView view) {
-        sInstance.mView = view;
-    }
-
     private AccessibleKeyboardViewProxy() {
         // Not publicly instantiable.
     }
@@ -73,34 +72,39 @@
         mGestureDetector = new KeyboardFlickGestureDetector(inputMethod);
     }
 
-    public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) {
-        if (mView == null) {
-            Log.e(TAG, "No keyboard view set!");
-            return false;
+    /**
+     * Sets the view wrapped by this proxy.
+     *
+     * @param view The view to wrap.
+     */
+    public void setView(LatinKeyboardView view) {
+        if (view == null) {
+            // Ignore null views.
+            return;
         }
 
-        switch (event.getEventType()) {
-        case AccessibilityEventCompatUtils.TYPE_VIEW_HOVER_ENTER:
-            final Key key = mLastHoverKey;
+        mView = view;
 
-            if (key == null)
-                break;
+        // Ensure that the view has an accessibility delegate.
+        ViewCompat.setAccessibilityDelegate(view, this);
+    }
 
-            final EditorInfo info = mInputMethod.getCurrentInputEditorInfo();
-            final boolean shouldObscure = AccessibilityUtils.getInstance().shouldObscureInput(info);
-            final CharSequence description = KeyCodeDescriptionMapper.getInstance()
-                    .getDescriptionForKey(mView.getContext(), mView.getKeyboard(), key,
-                            shouldObscure);
-
-            if (description == null)
-                return false;
-
-            event.getText().add(description);
-
-            break;
+    /**
+     * Proxy method for View.getAccessibilityNodeProvider(). This method is
+     * called in SDK version 15 and higher to obtain the virtual node hierarchy
+     * provider.
+     *
+     * @return The accessibility node provider for the current keyboard.
+     */
+    @Override
+    public AccessibilityEntityProvider getAccessibilityNodeProvider(View host) {
+        // Instantiate the provide only when requested. Since the system
+        // will call this method multiple times it is a good practice to
+        // cache the provider instance.
+        if (mAccessibilityNodeProvider == null) {
+            mAccessibilityNodeProvider = new AccessibilityEntityProvider(mView, mInputMethod);
         }
-
-        return true;
+        return mAccessibilityNodeProvider;
     }
 
     /**
@@ -123,46 +127,94 @@
      * @param event The touch exploration hover event.
      * @return {@code true} if the event was handled
      */
-    /*package*/ boolean onHoverEventInternal(MotionEvent event, PointerTracker tracker) {
+    /* package */boolean onHoverEventInternal(MotionEvent event, PointerTracker tracker) {
         final int x = (int) event.getX();
         final int y = (int) event.getY();
+        final Key key = tracker.getKeyOn(x, y);
+        final Key previousKey = mLastHoverKey;
+
+        mLastHoverKey = key;
 
         switch (event.getAction()) {
         case MotionEventCompatUtils.ACTION_HOVER_ENTER:
-        case MotionEventCompatUtils.ACTION_HOVER_MOVE:
-            final Key key = tracker.getKeyOn(x, y);
-
-            if (key != mLastHoverKey) {
-                fireKeyHoverEvent(mLastHoverKey, false);
-                mLastHoverKey = key;
-                fireKeyHoverEvent(mLastHoverKey, true);
+        case MotionEventCompatUtils.ACTION_HOVER_EXIT:
+            return onHoverKey(key, event);
+        case MotionEventCompat.ACTION_HOVER_MOVE:
+            if (key != previousKey) {
+                return onTransitionKey(key, previousKey, event);
+            } else {
+                return onHoverKey(key, event);
             }
-
-            return true;
         }
 
         return false;
     }
 
-    private void fireKeyHoverEvent(Key key, boolean entering) {
-        if (mListener == null) {
-            Log.e(TAG, "No accessible keyboard action listener set!");
-            return;
+    /**
+     * Simulates a transition between two {@link Key}s by sending a HOVER_EXIT
+     * on the previous key, a HOVER_ENTER on the current key, and a HOVER_MOVE
+     * on the current key.
+     *
+     * @param currentKey The currently hovered key.
+     * @param previousKey The previously hovered key.
+     * @param event The event that triggered the transition.
+     * @return {@code true} if the event was handled.
+     */
+    private boolean onTransitionKey(Key currentKey, Key previousKey, MotionEvent event) {
+        final int savedAction = event.getAction();
+
+        event.setAction(MotionEventCompatUtils.ACTION_HOVER_EXIT);
+        onHoverKey(previousKey, event);
+
+        event.setAction(MotionEventCompatUtils.ACTION_HOVER_ENTER);
+        onHoverKey(currentKey, event);
+
+        event.setAction(MotionEventCompat.ACTION_HOVER_MOVE);
+        final boolean handled = onHoverKey(currentKey, event);
+
+        event.setAction(savedAction);
+
+        return handled;
+    }
+
+    /**
+     * Handles a hover event on a key. If {@link Key} extended View, this would
+     * be analogous to calling View.onHoverEvent(MotionEvent).
+     *
+     * @param key The currently hovered key.
+     * @param event The hover event.
+     * @return {@code true} if the event was handled.
+     */
+    private boolean onHoverKey(Key key, MotionEvent event) {
+        // Null keys can't receive events.
+        if (key == null) {
+            return false;
         }
 
-        if (mView == null) {
-            Log.e(TAG, "No keyboard view set!");
-            return;
+        switch (event.getAction()) {
+        case MotionEventCompatUtils.ACTION_HOVER_ENTER:
+            sendAccessibilityEventForKey(key, AccessibilityEventCompat.TYPE_VIEW_HOVER_ENTER);
+            break;
+        case MotionEventCompatUtils.ACTION_HOVER_EXIT:
+            sendAccessibilityEventForKey(key, AccessibilityEventCompat.TYPE_VIEW_HOVER_EXIT);
+            break;
         }
 
-        if (key == null)
-            return;
+        return true;
+    }
 
-        if (entering) {
-            mView.sendAccessibilityEvent(AccessibilityEventCompatUtils.TYPE_VIEW_HOVER_ENTER);
-        } else {
-            mView.sendAccessibilityEvent(AccessibilityEventCompatUtils.TYPE_VIEW_HOVER_EXIT);
-        }
+    /**
+     * Populates and sends an {@link AccessibilityEvent} for the specified key.
+     *
+     * @param key The key to send an event for.
+     * @param eventType The type of event to send.
+     */
+    private void sendAccessibilityEventForKey(Key key, int eventType) {
+        final AccessibilityEntityProvider nodeProvider = getAccessibilityNodeProvider(null);
+        final AccessibilityEvent event = nodeProvider.createAccessibilityEvent(key, eventType);
+
+        // Propagates the event up the view hierarchy.
+        ViewParentCompatUtils.requestSendAccessibilityEvent(mView.getParent(), mView, event);
     }
 
     private class KeyboardFlickGestureDetector extends FlickGestureDetector {
diff --git a/java/src/com/android/inputmethod/accessibility/FlickGestureDetector.java b/java/src/com/android/inputmethod/accessibility/FlickGestureDetector.java
index db12f76..eaa4ddf 100644
--- a/java/src/com/android/inputmethod/accessibility/FlickGestureDetector.java
+++ b/java/src/com/android/inputmethod/accessibility/FlickGestureDetector.java
@@ -18,6 +18,7 @@
 
 import android.content.Context;
 import android.os.Message;
+import android.support.v4.view.MotionEventCompat;
 import android.view.MotionEvent;
 import android.view.ViewConfiguration;
 
@@ -32,7 +33,7 @@
  * properties:
  * <ul>
  *   <li>Begins with a {@link MotionEventCompatUtils#ACTION_HOVER_ENTER} event
- *   <li>Contains any number of {@link MotionEventCompatUtils#ACTION_HOVER_MOVE}
+ *   <li>Contains any number of {@link MotionEventCompat#ACTION_HOVER_MOVE}
  *       events
  *   <li>Ends with a {@link MotionEventCompatUtils#ACTION_HOVER_EXIT} event
  *   <li>Maximum duration of 250 milliseconds
@@ -128,7 +129,7 @@
         final float distanceSquare = calculateDistanceSquare(mCachedHoverEnter, event);
 
         switch (event.getAction()) {
-        case MotionEventCompatUtils.ACTION_HOVER_MOVE:
+        case MotionEventCompat.ACTION_HOVER_MOVE:
             // Consume all valid move events before timeout.
             return true;
         case MotionEventCompatUtils.ACTION_HOVER_EXIT:
diff --git a/java/src/com/android/inputmethod/accessibility/KeyCodeDescriptionMapper.java b/java/src/com/android/inputmethod/accessibility/KeyCodeDescriptionMapper.java
index f0dba4a..3d861c2 100644
--- a/java/src/com/android/inputmethod/accessibility/KeyCodeDescriptionMapper.java
+++ b/java/src/com/android/inputmethod/accessibility/KeyCodeDescriptionMapper.java
@@ -18,6 +18,7 @@
 
 import android.content.Context;
 import android.text.TextUtils;
+import android.util.Log;
 
 import com.android.inputmethod.keyboard.Key;
 import com.android.inputmethod.keyboard.Keyboard;
@@ -27,6 +28,8 @@
 import java.util.HashMap;
 
 public class KeyCodeDescriptionMapper {
+    private static final String TAG = KeyCodeDescriptionMapper.class.getSimpleName();
+
     // The resource ID of the string spoken for obscured keys
     private static final int OBSCURED_KEY_RES_ID = R.string.spoken_description_dot;
 
@@ -87,12 +90,12 @@
      * @return a character sequence describing the action performed by pressing
      *         the key
      */
-    public CharSequence getDescriptionForKey(Context context, Keyboard keyboard, Key key,
+    public String getDescriptionForKey(Context context, Keyboard keyboard, Key key,
             boolean shouldObscure) {
         final int code = key.mCode;
 
         if (code == Keyboard.CODE_SWITCH_ALPHA_SYMBOL) {
-            final CharSequence description = getDescriptionForSwitchAlphaSymbol(context, keyboard);
+            final String description = getDescriptionForSwitchAlphaSymbol(context, keyboard);
             if (description != null)
                 return description;
         }
@@ -128,7 +131,7 @@
      * @return a character sequence describing the action performed by pressing
      *         the key
      */
-    private CharSequence getDescriptionForSwitchAlphaSymbol(Context context, Keyboard keyboard) {
+    private String getDescriptionForSwitchAlphaSymbol(Context context, Keyboard keyboard) {
         final KeyboardId keyboardId = keyboard.mId;
         final int elementId = keyboardId.mElementId;
         final int resId;
@@ -152,10 +155,7 @@
             resId = R.string.spoken_description_to_numeric;
             break;
         default:
-            resId = -1;
-        }
-
-        if (resId < 0) {
+            Log.e(TAG, "Missing description for keyboard element ID:" + elementId);
             return null;
         }
 
@@ -169,7 +169,7 @@
      * @param keyboard The keyboard on which the key resides.
      * @return A context-sensitive description of the "Shift" key.
      */
-    private CharSequence getDescriptionForShiftKey(Context context, Keyboard keyboard) {
+    private String getDescriptionForShiftKey(Context context, Keyboard keyboard) {
         final KeyboardId keyboardId = keyboard.mId;
         final int elementId = keyboardId.mElementId;
         final int resId;
@@ -212,7 +212,7 @@
      * @return a character sequence describing the action performed by pressing
      *         the key
      */
-    private CharSequence getDescriptionForKeyCode(Context context, Keyboard keyboard, Key key,
+    private String getDescriptionForKeyCode(Context context, Keyboard keyboard, Key key,
             boolean shouldObscure) {
         final int code = key.mCode;
 
diff --git a/java/src/com/android/inputmethod/compat/AccessibilityEventCompatUtils.java b/java/src/com/android/inputmethod/compat/AccessibilityEventCompatUtils.java
deleted file mode 100644
index 2fa9d87..0000000
--- a/java/src/com/android/inputmethod/compat/AccessibilityEventCompatUtils.java
+++ /dev/null
@@ -1,22 +0,0 @@
-/*
- * Copyright (C) 2011 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.inputmethod.compat;
-
-public class AccessibilityEventCompatUtils {
-    public static final int TYPE_VIEW_HOVER_ENTER = 0x80;
-    public static final int TYPE_VIEW_HOVER_EXIT = 0x100;
-}
diff --git a/java/src/com/android/inputmethod/compat/AccessibilityManagerCompatWrapper.java b/java/src/com/android/inputmethod/compat/AccessibilityManagerCompatUtils.java
similarity index 74%
rename from java/src/com/android/inputmethod/compat/AccessibilityManagerCompatWrapper.java
rename to java/src/com/android/inputmethod/compat/AccessibilityManagerCompatUtils.java
index a30af0f..41b6a07 100644
--- a/java/src/com/android/inputmethod/compat/AccessibilityManagerCompatWrapper.java
+++ b/java/src/com/android/inputmethod/compat/AccessibilityManagerCompatUtils.java
@@ -20,17 +20,15 @@
 
 import java.lang.reflect.Method;
 
-public class AccessibilityManagerCompatWrapper {
+public class AccessibilityManagerCompatUtils {
     private static final Method METHOD_isTouchExplorationEnabled = CompatUtils.getMethod(
             AccessibilityManager.class, "isTouchExplorationEnabled");
 
-    private final AccessibilityManager mManager;
-
-    public AccessibilityManagerCompatWrapper(AccessibilityManager manager) {
-        mManager = manager;
+    private AccessibilityManagerCompatUtils() {
+        // This class is non-instantiable.
     }
 
-    public boolean isTouchExplorationEnabled() {
-        return (Boolean) CompatUtils.invoke(mManager, false, METHOD_isTouchExplorationEnabled);
+    public static boolean isTouchExplorationEnabled(AccessibilityManager receiver) {
+        return (Boolean) CompatUtils.invoke(receiver, false, METHOD_isTouchExplorationEnabled);
     }
 }
diff --git a/java/src/com/android/inputmethod/compat/MotionEventCompatUtils.java b/java/src/com/android/inputmethod/compat/MotionEventCompatUtils.java
index 8518a4a..eca922e 100644
--- a/java/src/com/android/inputmethod/compat/MotionEventCompatUtils.java
+++ b/java/src/com/android/inputmethod/compat/MotionEventCompatUtils.java
@@ -17,7 +17,7 @@
 package com.android.inputmethod.compat;
 
 public class MotionEventCompatUtils {
-    public static final int ACTION_HOVER_MOVE = 0x7;
+    // TODO(alanv): Remove after these are added to MotionEventCompat.
     public static final int ACTION_HOVER_ENTER = 0x9;
     public static final int ACTION_HOVER_EXIT = 0xA;
 }
diff --git a/java/src/com/android/inputmethod/compat/ViewParentCompatUtils.java b/java/src/com/android/inputmethod/compat/ViewParentCompatUtils.java
new file mode 100644
index 0000000..d19bc3a
--- /dev/null
+++ b/java/src/com/android/inputmethod/compat/ViewParentCompatUtils.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.inputmethod.compat;
+
+import android.view.View;
+import android.view.ViewParent;
+import android.view.accessibility.AccessibilityEvent;
+
+import java.lang.reflect.Method;
+
+public class ViewParentCompatUtils {
+    private static final Method METHOD_requestSendAccessibilityEvent = CompatUtils.getMethod(
+            ViewParent.class, "requestSendAccessibilityEvent", View.class,
+            AccessibilityEvent.class);
+
+    /**
+     * Called by a child to request from its parent to send an {@link AccessibilityEvent}.
+     * The child has already populated a record for itself in the event and is delegating
+     * to its parent to send the event. The parent can optionally add a record for itself.
+     * <p>
+     * Note: An accessibility event is fired by an individual view which populates the
+     *       event with a record for its state and requests from its parent to perform
+     *       the sending. The parent can optionally add a record for itself before
+     *       dispatching the request to its parent. A parent can also choose not to
+     *       respect the request for sending the event. The accessibility event is sent
+     *       by the topmost view in the view tree.</p>
+     *
+     * @param child The child which requests sending the event.
+     * @param event The event to be sent.
+     * @return True if the event was sent.
+     */
+    public static boolean requestSendAccessibilityEvent(
+            ViewParent receiver, View child, AccessibilityEvent event) {
+        return (Boolean) CompatUtils.invoke(
+                receiver, false, METHOD_requestSendAccessibilityEvent, child, event);
+    }
+}
diff --git a/java/src/com/android/inputmethod/keyboard/KeyboardSwitcher.java b/java/src/com/android/inputmethod/keyboard/KeyboardSwitcher.java
index 622e583..e1c6f26 100644
--- a/java/src/com/android/inputmethod/keyboard/KeyboardSwitcher.java
+++ b/java/src/com/android/inputmethod/keyboard/KeyboardSwitcher.java
@@ -379,7 +379,7 @@
 
         // This always needs to be set since the accessibility state can
         // potentially change without the input view being re-created.
-        AccessibleKeyboardViewProxy.setView(mKeyboardView);
+        AccessibleKeyboardViewProxy.getInstance().setView(mKeyboardView);
 
         return mCurrentInputView;
     }
diff --git a/java/src/com/android/inputmethod/keyboard/LatinKeyboardView.java b/java/src/com/android/inputmethod/keyboard/LatinKeyboardView.java
index 9a0fe1e..00570fb 100644
--- a/java/src/com/android/inputmethod/keyboard/LatinKeyboardView.java
+++ b/java/src/com/android/inputmethod/keyboard/LatinKeyboardView.java
@@ -34,7 +34,6 @@
 import android.view.View;
 import android.view.ViewConfiguration;
 import android.view.ViewGroup;
-import android.view.accessibility.AccessibilityEvent;
 import android.widget.PopupWindow;
 
 import com.android.inputmethod.accessibility.AccessibilityUtils;
@@ -735,16 +734,6 @@
         VoiceProxy.getInstance().onAttachedToWindow();
     }
 
-    @Override
-    public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) {
-        if (AccessibilityUtils.getInstance().isTouchExplorationEnabled()) {
-            return AccessibleKeyboardViewProxy.getInstance().dispatchPopulateAccessibilityEvent(
-                    event) || super.dispatchPopulateAccessibilityEvent(event);
-        }
-
-        return super.dispatchPopulateAccessibilityEvent(event);
-    }
-
     /**
      * Receives hover events from the input framework. This method overrides
      * View.dispatchHoverEvent(MotionEvent) on SDK version ICS or higher. On