Merge "Fix null pointer exception in year picker Retain year picker position offset on orientation change" into jb-mr2-dev
diff --git a/src/com/android/datetimepicker/date/DayPickerView.java b/src/com/android/datetimepicker/date/DayPickerView.java
index 1b0e871..fcd1d8c 100644
--- a/src/com/android/datetimepicker/date/DayPickerView.java
+++ b/src/com/android/datetimepicker/date/DayPickerView.java
@@ -347,4 +347,59 @@
     public void onDateChanged() {
         goTo(mController.getSelectedDay(), false, true, true);
     }
+
+    /**
+     * Attempts to return the date that has accessibility focus.
+     *
+     * @return The date that has accessibility focus, or {@code null} if no date
+     *         has focus.
+     */
+    private CalendarDay findAccessibilityFocus() {
+        final int childCount = getChildCount();
+        for (int i = 0; i < childCount; i++) {
+            final View child = getChildAt(i);
+            if (child instanceof SimpleMonthView) {
+                final CalendarDay focus = ((SimpleMonthView) child).getAccessibilityFocus();
+                if (focus != null) {
+                    // Clear focus to avoid ListView bug in Jelly Bean MR1.
+                    ((SimpleMonthView) child).clearAccessibilityFocus();
+                    return focus;
+                }
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * Attempts to restore accessibility focus to a given date. No-op if
+     * {@code day} is {@code null}.
+     *
+     * @param day The date that should receive accessibility focus
+     * @return {@code true} if focus was restored
+     */
+    private boolean restoreAccessibilityFocus(CalendarDay day) {
+        if (day == null) {
+            return false;
+        }
+
+        final int childCount = getChildCount();
+        for (int i = 0; i < childCount; i++) {
+            final View child = getChildAt(i);
+            if (child instanceof SimpleMonthView) {
+                if (((SimpleMonthView) child).restoreAccessibilityFocus(day)) {
+                    return true;
+                }
+            }
+        }
+
+        return false;
+    }
+
+    @Override
+    protected void layoutChildren() {
+        final CalendarDay focusedDay = findAccessibilityFocus();
+        super.layoutChildren();
+        restoreAccessibilityFocus(focusedDay);
+    }
 }
diff --git a/src/com/android/datetimepicker/date/SimpleMonthAdapter.java b/src/com/android/datetimepicker/date/SimpleMonthAdapter.java
index 6d3ef7a..84be210 100644
--- a/src/com/android/datetimepicker/date/SimpleMonthAdapter.java
+++ b/src/com/android/datetimepicker/date/SimpleMonthAdapter.java
@@ -18,28 +18,26 @@
 
 import android.content.Context;
 import android.util.Log;
-import android.view.GestureDetector;
-import android.view.MotionEvent;
 import android.view.View;
-import android.view.View.OnTouchListener;
 import android.view.ViewGroup;
 import android.widget.AbsListView.LayoutParams;
 import android.widget.BaseAdapter;
 
+import com.android.datetimepicker.date.SimpleMonthView.OnDayClickListener;
+
 import java.util.Calendar;
 import java.util.HashMap;
 
 /**
  * An adapter for a list of {@link SimpleMonthView} items.
  */
-public class SimpleMonthAdapter extends BaseAdapter implements OnTouchListener {
+public class SimpleMonthAdapter extends BaseAdapter implements OnDayClickListener {
 
     private static final String TAG = "SimpleMonthAdapter";
 
     private final Context mContext;
     private final DatePickerController mController;
 
-    private GestureDetector mGestureDetector;
     private CalendarDay mSelectedDay;
 
     protected static int WEEK_7_OVERHANG_HEIGHT = 7;
@@ -121,7 +119,6 @@
      * Set up the gesture detector and selected time
      */
     protected void init() {
-        mGestureDetector = new GestureDetector(mContext, new CalendarGestureListener());
         mSelectedDay = new CalendarDay(System.currentTimeMillis());
     }
 
@@ -156,7 +153,7 @@
                     LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
             v.setLayoutParams(params);
             v.setClickable(true);
-            v.setOnTouchListener(this);
+            v.setOnDayClickListener(this);
         }
         if (drawingParams == null) {
             drawingParams = new HashMap<String, Integer>();
@@ -190,15 +187,10 @@
     }
 
     @Override
-    public boolean onTouch(View v, MotionEvent event) {
-        if (mGestureDetector.onTouchEvent(event)) {
-            CalendarDay day = ((SimpleMonthView) v).getDayFromLocation(event.getX(), event.getY());
-            if (day != null) {
-                onDayTapped(day);
-            }
-            return true;
+    public void onDayClick(SimpleMonthView view, CalendarDay day) {
+        if (day != null) {
+            onDayTapped(day);
         }
-        return false;
     }
 
     /**
@@ -211,15 +203,4 @@
         mController.onDayOfMonthSelected(day.year, day.month, day.day);
         setSelectedDay(day);
     }
-
-    /**
-     * This is here so we can identify single tap events and set the selected
-     * day correctly
-     */
-    protected class CalendarGestureListener extends GestureDetector.SimpleOnGestureListener {
-        @Override
-        public boolean onSingleTapUp(MotionEvent e) {
-            return true;
-        }
-    }
 }
diff --git a/src/com/android/datetimepicker/date/SimpleMonthView.java b/src/com/android/datetimepicker/date/SimpleMonthView.java
index c3a4346..bcb592f 100644
--- a/src/com/android/datetimepicker/date/SimpleMonthView.java
+++ b/src/com/android/datetimepicker/date/SimpleMonthView.java
@@ -22,17 +22,29 @@
 import android.graphics.Paint;
 import android.graphics.Paint.Align;
 import android.graphics.Paint.Style;
+import android.graphics.Rect;
 import android.graphics.Typeface;
+import android.os.Bundle;
+import android.support.v4.view.ViewCompat;
+import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
+import android.text.format.DateUtils;
 import android.text.format.Time;
+import android.util.SparseArray;
+import android.view.MotionEvent;
 import android.view.View;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityNodeInfo;
 
 import com.android.datetimepicker.R;
 import com.android.datetimepicker.Utils;
 import com.android.datetimepicker.date.SimpleMonthAdapter.CalendarDay;
+import com.googlecode.eyesfree.utils.TouchExplorationHelper;
 
 import java.security.InvalidParameterException;
 import java.util.Calendar;
+import java.util.Formatter;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Locale;
 
 /**
@@ -119,6 +131,9 @@
     protected Paint mSelectedCirclePaint;
     protected Paint mMonthDayLabelPaint;
 
+    private final Formatter mFormatter;
+    private final StringBuilder mStringBuilder;
+
     // The Julian day of the first day displayed by this item
     protected int mFirstJulianDay = -1;
     // The month of the first day in this week
@@ -152,9 +167,15 @@
 
     private final Calendar mCalendar;
     private final Calendar mDayLabelCalendar;
+    private final MonthViewNodeProvider mNodeProvider;
 
     private int mNumRows = DEFAULT_NUM_ROWS;
 
+    // Optional listener for handling day click actions
+    private OnDayClickListener mOnDayClickListener;
+    // Whether to prevent setting the accessibility delegate
+    private boolean mLockAccessibilityDelegate;
+
     protected int mDayTextColor;
     protected int mTodayNumberColor;
     protected int mMonthTitleColor;
@@ -176,6 +197,9 @@
         mMonthTitleColor = res.getColor(R.color.white);
         mMonthTitleBGColor = res.getColor(R.color.circle_background);
 
+        mStringBuilder = new StringBuilder(50);
+        mFormatter = new Formatter(mStringBuilder, Locale.getDefault());
+
         MINI_DAY_NUMBER_TEXT_SIZE = res.getDimensionPixelSize(R.dimen.day_number_size);
         MONTH_LABEL_TEXT_SIZE = res.getDimensionPixelSize(R.dimen.month_label_size);
         MONTH_DAY_LABEL_TEXT_SIZE = res.getDimensionPixelSize(R.dimen.month_day_label_text_size);
@@ -185,10 +209,52 @@
 
         mRowHeight = (res.getDimensionPixelOffset(R.dimen.date_picker_view_animator_height)
                 - MONTH_HEADER_SIZE) / MAX_NUM_ROWS;
+
+        // Set up accessibility components.
+        mNodeProvider = new MonthViewNodeProvider(context, this);
+        ViewCompat.setAccessibilityDelegate(this, mNodeProvider.getAccessibilityDelegate());
+        ViewCompat.setImportantForAccessibility(this, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES);
+        mLockAccessibilityDelegate = true;
+
         // Sets up any standard paints that will be used
         initView();
     }
 
+    @Override
+    public void setAccessibilityDelegate(AccessibilityDelegate delegate) {
+        // Workaround for a JB MR1 issue where accessibility delegates on
+        // top-level ListView items are overwritten.
+        if (!mLockAccessibilityDelegate) {
+            super.setAccessibilityDelegate(delegate);
+        }
+    }
+
+    public void setOnDayClickListener(OnDayClickListener listener) {
+        mOnDayClickListener = listener;
+    }
+
+    @Override
+    public boolean onHoverEvent(MotionEvent event) {
+        // First right-of-refusal goes the touch exploration helper.
+        if (mNodeProvider.onHover(this, event)) {
+            return true;
+        }
+        return super.onHoverEvent(event);
+    }
+
+    @Override
+    public boolean onTouchEvent(MotionEvent event) {
+        switch (event.getAction()) {
+            case MotionEvent.ACTION_UP:
+                final CalendarDay day = getDayFromLocation(event.getX(), event.getY());
+                if (day != null) {
+                    onDayClick(day);
+                }
+                break;
+        }
+        return true;
+    }
+
     /**
      * Sets up the text and style properties for painting. Override this if you
      * want to use a different paint.
@@ -301,6 +367,9 @@
             }
         }
         mNumRows = calculateNumRows();
+
+        // Invalidate cached accessibility information.
+        mNodeProvider.invalidateParent();
     }
 
     public void reuse() {
@@ -330,17 +399,22 @@
     @Override
     protected void onSizeChanged(int w, int h, int oldw, int oldh) {
         mWidth = w;
+
+        // Invalidate cached accessibility information.
+        mNodeProvider.invalidateParent();
     }
 
     private void drawMonthTitle(Canvas canvas) {
         int x = (mWidth + 2 * mPadding) / 2;
         int y = (MONTH_HEADER_SIZE - MONTH_DAY_LABEL_TEXT_SIZE) / 2 + (MONTH_LABEL_TEXT_SIZE / 3);
-        StringBuffer sbuf = new StringBuffer();
-        sbuf.append(mCalendar.getDisplayName(Calendar.MONTH, Calendar.LONG,
-                Locale.getDefault()));
-        sbuf.append(" ");
-        sbuf.append(String.format("%d", mYear));
-        canvas.drawText(sbuf.toString(), x, y, mMonthTitlePaint);
+        int flags = DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR
+                | DateUtils.FORMAT_NO_MONTH_DAY;
+
+        mStringBuilder.setLength(0);
+        long millis = mCalendar.getTimeInMillis();
+        String title = DateUtils.formatDateRange(getContext(), mFormatter, millis, millis, flags,
+                Time.getCurrentTimezone()).toString();
+        canvas.drawText(title, x, y, mMonthTitlePaint);
     }
 
     private void drawMonthDayLabels(Canvas canvas) {
@@ -421,4 +495,190 @@
         return new CalendarDay(mYear, mMonth, day);
     }
 
+    /**
+     * Called when the user clicks on a day. Handles callbacks to the
+     * {@link OnDayClickListener} if one is set.
+     *
+     * @param day A time object representing the day that was clicked
+     */
+    private void onDayClick(CalendarDay day) {
+        if (mOnDayClickListener != null) {
+            mOnDayClickListener.onDayClick(this, day);
+        }
+
+        // This is a no-op if accessibility is turned off.
+        mNodeProvider.sendEventForItem(day, AccessibilityEvent.TYPE_VIEW_CLICKED);
+    }
+
+    /**
+     * @return The date that has accessibility focus, or {@code null} if no date
+     *         has focus
+     */
+    public CalendarDay getAccessibilityFocus() {
+        return mNodeProvider.getFocusedItem();
+    }
+
+    /**
+     * Clears accessibility focus within the view. No-op if the view does not
+     * contain accessibility focus.
+     */
+    public void clearAccessibilityFocus() {
+        mNodeProvider.clearFocusedItem();
+    }
+
+    /**
+     * Attempts to restore accessibility focus to the specified date.
+     *
+     * @param day The date which should receive focus
+     * @return {@code false} if the date is not valid for this month view, or
+     *         {@code true} if the date received focus
+     */
+    public boolean restoreAccessibilityFocus(CalendarDay day) {
+        if ((day.year != mYear) || (day.month != mMonth) || (day.day > mNumCells)) {
+            return false;
+        }
+
+        mNodeProvider.setFocusedItem(day);
+        return true;
+    }
+
+    /**
+     * Provides a virtual view hierarchy for interfacing with an accessibility
+     * service.
+     */
+    private class MonthViewNodeProvider extends TouchExplorationHelper<CalendarDay> {
+        private final SparseArray<CalendarDay> mCachedItems = new SparseArray<CalendarDay>();
+        private final Rect mTempRect = new Rect();
+
+        public MonthViewNodeProvider(Context context, View parent) {
+            super(context, parent);
+        }
+
+        @Override
+        public void invalidateItem(CalendarDay item) {
+            super.invalidateItem(item);
+            mCachedItems.delete(getIdForItem(item));
+        }
+
+        @Override
+        public void invalidateParent() {
+            super.invalidateParent();
+            mCachedItems.clear();
+        }
+
+        @Override
+        protected boolean performActionForItem(CalendarDay item, int action, Bundle arguments) {
+            switch (action) {
+                case AccessibilityNodeInfo.ACTION_CLICK:
+                    onDayClick(item);
+                    return true;
+            }
+
+            return false;
+        }
+
+        @Override
+        protected void populateEventForItem(CalendarDay item, AccessibilityEvent event) {
+            event.setContentDescription(getItemDescription(item));
+        }
+
+        @Override
+        protected void populateNodeForItem(CalendarDay item, AccessibilityNodeInfoCompat node) {
+            getItemBounds(item, mTempRect);
+
+            node.setContentDescription(getItemDescription(item));
+            node.setBoundsInParent(mTempRect);
+            node.addAction(AccessibilityNodeInfo.ACTION_CLICK);
+
+            if (item.day == mSelectedDay) {
+                node.setSelected(true);
+            }
+        }
+
+        @Override
+        protected void getVisibleItems(List<CalendarDay> items) {
+            // TODO: Optimize, only return items visible within parent bounds.
+            for (int day = 1; day <= mNumCells; day++) {
+                items.add(getItemForId(day));
+            }
+        }
+
+        @Override
+        protected CalendarDay getItemAt(float x, float y) {
+            return getDayFromLocation(x, y);
+        }
+
+        @Override
+        protected int getIdForItem(CalendarDay item) {
+            return item.day;
+        }
+
+        @Override
+        protected CalendarDay getItemForId(int id) {
+            if ((id < 1) || (id > mNumCells)) {
+                return null;
+            }
+
+            final CalendarDay item;
+            if (mCachedItems.indexOfKey(id) >= 0) {
+                item = mCachedItems.get(id);
+            } else {
+                item = new CalendarDay(mYear, mMonth, id);
+                mCachedItems.put(id, item);
+            }
+
+            return item;
+        }
+
+        /**
+         * Calculates the bounding rectangle of a given time object.
+         *
+         * @param item The time object to calculate bounds for
+         * @param rect The rectangle in which to store the bounds
+         */
+        private void getItemBounds(CalendarDay item, Rect rect) {
+            final int offsetX = mPadding;
+            final int offsetY = MONTH_HEADER_SIZE;
+            final int cellHeight = mRowHeight;
+            final int cellWidth = ((mWidth - (2 * mPadding)) / mNumDays);
+            final int index = ((item.day - 1) + findDayOffset());
+            final int row = (index / mNumDays);
+            final int column = (index % mNumDays);
+            final int x = (offsetX + (column * cellWidth));
+            final int y = (offsetY + (row * cellHeight));
+
+            rect.set(x, y, (x + cellWidth), (y + cellHeight));
+        }
+
+        /**
+         * Generates a description for a given time object. Since this
+         * description will be spoken, the components are ordered by descending
+         * specificity as DAY MONTH YEAR.
+         *
+         * @param item The time object to generate a description for
+         * @return A description of the time object
+         */
+        private CharSequence getItemDescription(CalendarDay item) {
+            final StringBuffer sbuf = new StringBuffer();
+            sbuf.append(String.format("%d", item.day));
+            sbuf.append(" ");
+            sbuf.append(mCalendar.getDisplayName(Calendar.MONTH, Calendar.LONG,
+                    Locale.getDefault()));
+            sbuf.append(" ");
+            sbuf.append(String.format("%d", mYear));
+
+            if (item.day == mSelectedDay) {
+                return getContext().getString(R.string.item_is_selected, sbuf);
+            }
+
+            return sbuf;
+        }
+    }
+
+    /**
+     * Handles callbacks when the user clicks on a time object.
+     */
+    public interface OnDayClickListener {
+        public void onDayClick(SimpleMonthView view, CalendarDay day);
+    }
 }
diff --git a/src/com/googlecode/eyesfree/utils/TouchExplorationHelper.java b/src/com/googlecode/eyesfree/utils/TouchExplorationHelper.java
new file mode 100644
index 0000000..b9df653
--- /dev/null
+++ b/src/com/googlecode/eyesfree/utils/TouchExplorationHelper.java
@@ -0,0 +1,475 @@
+/*
+ * Copyright (C) 2012 Google Inc.
+ *
+ * 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.googlecode.eyesfree.utils;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.os.Bundle;
+import android.support.v4.view.AccessibilityDelegateCompat;
+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.text.TextUtils;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityManager;
+
+import java.util.LinkedList;
+import java.util.List;
+
+public abstract class TouchExplorationHelper<T> extends AccessibilityNodeProviderCompat
+        implements View.OnHoverListener {
+    /** Virtual node identifier value for invalid nodes. */
+    public static final int INVALID_ID = Integer.MIN_VALUE;
+
+    private final Rect mTempScreenRect = new Rect();
+    private final Rect mTempParentRect = new Rect();
+    private final Rect mTempVisibleRect = new Rect();
+    private final int[] mTempGlobalRect = new int[2];
+
+    private final AccessibilityManager mManager;
+
+    private View mParentView;
+    private int mFocusedItemId = INVALID_ID;
+    private T mCurrentItem = null;
+
+    /**
+     * Constructs a new touch exploration helper.
+     *
+     * @param context The parent context.
+     */
+    public TouchExplorationHelper(Context context, View parentView) {
+        mManager = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
+        mParentView = parentView;
+    }
+
+    /**
+     * @return The current accessibility focused item, or {@code null} if no
+     *         item is focused.
+     */
+    public T getFocusedItem() {
+        return getItemForId(mFocusedItemId);
+    }
+
+    /**
+     * Clears the current accessibility focused item.
+     */
+    public void clearFocusedItem() {
+        final int itemId = mFocusedItemId;
+        if (itemId == INVALID_ID) {
+            return;
+        }
+
+        performAction(itemId, AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS, null);
+    }
+
+    /**
+     * Requests accessibility focus be placed on the specified item.
+     *
+     * @param item The item to place focus on.
+     */
+    public void setFocusedItem(T item) {
+        final int itemId = getIdForItem(item);
+        if (itemId == INVALID_ID) {
+            return;
+        }
+
+        performAction(itemId, AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS, null);
+    }
+
+    /**
+     * Invalidates cached information about the parent view.
+     * <p>
+     * You <b>must</b> call this method after adding or removing items from the
+     * parent view.
+     * </p>
+     */
+    public void invalidateParent() {
+        mParentView.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED);
+    }
+
+    /**
+     * Invalidates cached information for a particular item.
+     * <p>
+     * You <b>must</b> call this method when any of the properties set in
+     * {@link #populateNodeForItem(Object, AccessibilityNodeInfoCompat)} have
+     * changed.
+     * </p>
+     *
+     * @param item
+     */
+    public void invalidateItem(T item) {
+        sendEventForItem(item, AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED);
+    }
+
+    /**
+     * Populates an event of the specified type with information about an item
+     * and attempts to send it up through the view hierarchy.
+     *
+     * @param item The item for which to send an event.
+     * @param eventType The type of event to send.
+     * @return {@code true} if the event was sent successfully.
+     */
+    public boolean sendEventForItem(T item, int eventType) {
+        if (!mManager.isEnabled()) {
+            return false;
+        }
+
+        final AccessibilityEvent event = getEventForItem(item, eventType);
+        final ViewGroup group = (ViewGroup) mParentView.getParent();
+
+        return group.requestSendAccessibilityEvent(mParentView, event);
+    }
+
+    @Override
+    public AccessibilityNodeInfoCompat createAccessibilityNodeInfo(int virtualViewId) {
+        if (virtualViewId == View.NO_ID) {
+            return getNodeForParent();
+        }
+
+        final T item = getItemForId(virtualViewId);
+        if (item == null) {
+            return null;
+        }
+
+        final AccessibilityNodeInfoCompat node = AccessibilityNodeInfoCompat.obtain();
+        populateNodeForItemInternal(item, node);
+        return node;
+    }
+
+    @Override
+    public boolean performAction(int virtualViewId, int action, Bundle arguments) {
+        if (virtualViewId == View.NO_ID) {
+            return ViewCompat.performAccessibilityAction(mParentView, action, arguments);
+        }
+
+        final T item = getItemForId(virtualViewId);
+        if (item == null) {
+            return false;
+        }
+
+        boolean handled = false;
+
+        switch (action) {
+            case AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS:
+                if (mFocusedItemId != virtualViewId) {
+                    mFocusedItemId = virtualViewId;
+                    sendEventForItem(item, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED);
+                    handled = true;
+                }
+                break;
+            case AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS:
+                if (mFocusedItemId == virtualViewId) {
+                    mFocusedItemId = INVALID_ID;
+                    sendEventForItem(item, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED);
+                    handled = true;
+                }
+                break;
+        }
+
+        handled |= performActionForItem(item, action, arguments);
+
+        return handled;
+    }
+
+    @Override
+    public boolean onHover(View view, MotionEvent event) {
+        if (!mManager.isTouchExplorationEnabled()) {
+            return false;
+        }
+
+        switch (event.getAction()) {
+            case MotionEvent.ACTION_HOVER_ENTER:
+            case MotionEvent.ACTION_HOVER_MOVE:
+                final T item = getItemAt(event.getX(), event.getY());
+                setCurrentItem(item);
+                return true;
+            case MotionEvent.ACTION_HOVER_EXIT:
+                setCurrentItem(null);
+                return true;
+        }
+
+        return false;
+    }
+
+    private void setCurrentItem(T item) {
+        if (mCurrentItem == item) {
+            return;
+        }
+
+        if (mCurrentItem != null) {
+            sendEventForItem(mCurrentItem, AccessibilityEvent.TYPE_VIEW_HOVER_EXIT);
+        }
+
+        mCurrentItem = item;
+
+        if (mCurrentItem != null) {
+            sendEventForItem(mCurrentItem, AccessibilityEvent.TYPE_VIEW_HOVER_ENTER);
+        }
+    }
+
+    private AccessibilityEvent getEventForItem(T item, int eventType) {
+        final AccessibilityEvent event = AccessibilityEvent.obtain(eventType);
+        final AccessibilityRecordCompat record = new AccessibilityRecordCompat(event);
+        final int virtualDescendantId = getIdForItem(item);
+
+        // Ensure the client has good defaults.
+        event.setEnabled(true);
+
+        // Allow the client to populate the event.
+        populateEventForItem(item, event);
+
+        if (event.getText().isEmpty() && TextUtils.isEmpty(event.getContentDescription())) {
+            throw new RuntimeException(
+                    "You must add text or a content description in populateEventForItem()");
+        }
+
+        // Don't allow the client to override these properties.
+        event.setClassName(item.getClass().getName());
+        event.setPackageName(mParentView.getContext().getPackageName());
+        record.setSource(mParentView, virtualDescendantId);
+
+        return event;
+    }
+
+    private AccessibilityNodeInfoCompat getNodeForParent() {
+        final AccessibilityNodeInfoCompat info = AccessibilityNodeInfoCompat.obtain(mParentView);
+        ViewCompat.onInitializeAccessibilityNodeInfo(mParentView, info);
+
+        final LinkedList<T> items = new LinkedList<T>();
+        getVisibleItems(items);
+
+        for (T item : items) {
+            final int virtualDescendantId = getIdForItem(item);
+            info.addChild(mParentView, virtualDescendantId);
+        }
+
+        return info;
+    }
+
+    private AccessibilityNodeInfoCompat populateNodeForItemInternal(
+            T item, AccessibilityNodeInfoCompat node) {
+        final int virtualDescendantId = getIdForItem(item);
+
+        // Ensure the client has good defaults.
+        node.setEnabled(true);
+
+        // Allow the client to populate the node.
+        populateNodeForItem(item, node);
+
+        if (TextUtils.isEmpty(node.getText()) && TextUtils.isEmpty(node.getContentDescription())) {
+            throw new RuntimeException(
+                    "You must add text or a content description in populateNodeForItem()");
+        }
+
+        // Don't allow the client to override these properties.
+        node.setPackageName(mParentView.getContext().getPackageName());
+        node.setClassName(item.getClass().getName());
+        node.setParent(mParentView);
+        node.setSource(mParentView, virtualDescendantId);
+
+        if (mFocusedItemId == virtualDescendantId) {
+            node.addAction(AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS);
+        } else {
+            node.addAction(AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS);
+        }
+
+        node.getBoundsInParent(mTempParentRect);
+        if (mTempParentRect.isEmpty()) {
+            throw new RuntimeException("You must set parent bounds in populateNodeForItem()");
+        }
+
+        // Set the visibility based on the parent bound.
+        if (intersectVisibleToUser(mTempParentRect)) {
+            node.setVisibleToUser(true);
+            node.setBoundsInParent(mTempParentRect);
+        }
+
+        // Calculate screen-relative bound.
+        mParentView.getLocationOnScreen(mTempGlobalRect);
+        final int offsetX = mTempGlobalRect[0];
+        final int offsetY = mTempGlobalRect[1];
+        mTempScreenRect.set(mTempParentRect);
+        mTempScreenRect.offset(offsetX, offsetY);
+        node.setBoundsInScreen(mTempScreenRect);
+
+        return node;
+    }
+
+    /**
+     * Computes whether the specified {@link Rect} intersects with the visible
+     * portion of its parent {@link View}. Modifies {@code localRect} to
+     * contain only the visible portion.
+     *
+     * @param localRect A rectangle in local (parent) coordinates.
+     * @return Whether the specified {@link Rect} is visible on the screen.
+     */
+    private boolean intersectVisibleToUser(Rect localRect) {
+        // Missing or empty bounds mean this view is not visible.
+        if ((localRect == null) || localRect.isEmpty()) {
+            return false;
+        }
+
+        // Attached to invisible window means this view is not visible.
+        if (mParentView.getWindowVisibility() != View.VISIBLE) {
+            return false;
+        }
+
+        // An invisible predecessor or one with alpha zero means
+        // that this view is not visible to the user.
+        Object current = this;
+        while (current instanceof View) {
+            final View view = (View) current;
+            // We have attach info so this view is attached and there is no
+            // need to check whether we reach to ViewRootImpl on the way up.
+            if ((view.getAlpha() <= 0) || (view.getVisibility() != View.VISIBLE)) {
+                return false;
+            }
+            current = view.getParent();
+        }
+
+        // If no portion of the parent is visible, this view is not visible.
+        if (!mParentView.getLocalVisibleRect(mTempVisibleRect)) {
+            return false;
+        }
+
+        // Check if the view intersects the visible portion of the parent.
+        return localRect.intersect(mTempVisibleRect);
+    }
+
+    public AccessibilityDelegateCompat getAccessibilityDelegate() {
+        return mDelegate;
+    }
+
+    private final AccessibilityDelegateCompat mDelegate = new AccessibilityDelegateCompat() {
+        @Override
+        public void onInitializeAccessibilityEvent(View view, AccessibilityEvent event) {
+            super.onInitializeAccessibilityEvent(view, event);
+            event.setClassName(view.getClass().getName());
+        }
+
+        @Override
+        public void onInitializeAccessibilityNodeInfo(View view, AccessibilityNodeInfoCompat info) {
+            super.onInitializeAccessibilityNodeInfo(view, info);
+            info.setClassName(view.getClass().getName());
+        }
+
+        @Override
+        public AccessibilityNodeProviderCompat getAccessibilityNodeProvider(View host) {
+            return TouchExplorationHelper.this;
+        }
+    };
+
+    /**
+     * Performs an accessibility action on the specified item. See
+     * {@link AccessibilityNodeInfoCompat#performAction(int, Bundle)}.
+     * <p>
+     * The helper class automatically handles focus management resulting from
+     * {@link AccessibilityNodeInfoCompat#ACTION_ACCESSIBILITY_FOCUS} and
+     * {@link AccessibilityNodeInfoCompat#ACTION_CLEAR_ACCESSIBILITY_FOCUS}, so
+     * typically a developer only needs to handle actions added manually in the
+     * {{@link #populateNodeForItem(Object, AccessibilityNodeInfoCompat)}
+     * method.
+     * </p>
+     *
+     * @param item The item on which to perform the action.
+     * @param action The accessibility action to perform.
+     * @param arguments Arguments for the action, or optionally {@code null}.
+     * @return {@code true} if the action was performed successfully.
+     */
+    protected abstract boolean performActionForItem(T item, int action, Bundle arguments);
+
+    /**
+     * Populates an event with information about the specified item.
+     * <p>
+     * At a minimum, a developer must populate the event text by doing one of
+     * the following:
+     * <ul>
+     * <li>appending text to {@link AccessibilityEvent#getText()}</li>
+     * <li>populating a description with
+     * {@link AccessibilityEvent#setContentDescription(CharSequence)}</li>
+     * </ul>
+     * </p>
+     *
+     * @param item The item for which to populate the event.
+     * @param event The event to populate.
+     */
+    protected abstract void populateEventForItem(T item, AccessibilityEvent event);
+
+    /**
+     * Populates a node with information about the specified item.
+     * <p>
+     * At a minimum, a developer must:
+     * <ul>
+     * <li>populate the event text using
+     * {@link AccessibilityNodeInfoCompat#setText(CharSequence)} or
+     * {@link AccessibilityNodeInfoCompat#setContentDescription(CharSequence)}
+     * </li>
+     * <li>set the item's parent-relative bounds using
+     * {@link AccessibilityNodeInfoCompat#setBoundsInParent(Rect)}
+     * </ul>
+     *
+     * @param item The item for which to populate the node.
+     * @param node The node to populate.
+     */
+    protected abstract void populateNodeForItem(T item, AccessibilityNodeInfoCompat node);
+
+    /**
+     * Populates a list with the parent view's visible items.
+     * <p>
+     * The result of this method is cached until the developer calls
+     * {@link #invalidateParent()}.
+     * </p>
+     *
+     * @param items The list to populate with visible items.
+     */
+    protected abstract void getVisibleItems(List<T> items);
+
+    /**
+     * Returns the item under the specified parent-relative coordinates.
+     *
+     * @param x The parent-relative x coordinate.
+     * @param y The parent-relative y coordinate.
+     * @return The item under coordinates (x,y).
+     */
+    protected abstract T getItemAt(float x, float y);
+
+    /**
+     * Returns the unique identifier for an item. If the specified item does not
+     * exist, returns {@link #INVALID_ID}.
+     * <p>
+     * This result of this method must be consistent with
+     * {@link #getItemForId(int)}.
+     * </p>
+     *
+     * @param item The item whose identifier to return.
+     * @return A unique identifier, or {@link #INVALID_ID}.
+     */
+    protected abstract int getIdForItem(T item);
+
+    /**
+     * Returns the item for a unique identifier. If the specified item does not
+     * exist, returns {@code null}.
+     *
+     * @param id The identifier for the item to return.
+     * @return An item, or {@code null}.
+     */
+    protected abstract T getItemForId(int id);
+}