/* * 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.talkback.eventprocessor; import android.content.Context; import android.os.Build; import android.os.Bundle; import android.os.Message; import android.support.v4.view.accessibility.AccessibilityEventCompat; import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat; import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.util.Log; import android.view.accessibility.AccessibilityEvent; import android.view.accessibility.AccessibilityNodeInfo; import android.view.accessibility.AccessibilityRecord; import com.android.talkback.R; import com.android.talkback.SpeechController; import com.android.talkback.controller.CursorController; import com.android.utils.AccessibilityNodeInfoUtils; import com.android.utils.Role; import com.android.utils.StringBuilderUtils; import com.google.android.marvin.talkback.TalkBackService; import com.android.talkback.controller.FullScreenReadController; import com.android.utils.AccessibilityEventListener; import com.android.utils.WeakReferenceHandler; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.HashMap; /** * Manages scroll position feedback. If a VIEW_SCROLLED event passes through * this processor and no further events are received for a specified duration, a * "scroll position" message is spoken. */ public class ProcessorScrollPosition implements AccessibilityEventListener, CursorController.ScrollListener { /** Default pitch adjustment for text event feedback. */ private static final float DEFAULT_PITCH = 1.2f; /** Default rate adjustment for text event feedback. */ private static final float DEFAULT_RATE = 1.0f; private final HashMap<EventId, Integer> mCachedFromValues = new HashMap<>(); private final HashMap<EventId, Integer> mCachedItemCounts = new HashMap<>(); private final Bundle mSpeechParams = new Bundle(); private final ScrollPositionHandler mHandler = new ScrollPositionHandler(this); private final Context mContext; private final SpeechController mSpeechController; private final FullScreenReadController mFullScreenReadController; /** The last node that was auto-scrolled by the CursorController. */ private AccessibilityNodeInfoCompat mAutoScrollNode; public ProcessorScrollPosition(FullScreenReadController fullScreenReadController, SpeechController speechController, CursorController cursorController, TalkBackService context) { if (speechController == null) throw new IllegalStateException(); if (fullScreenReadController == null) throw new IllegalStateException(); if (cursorController == null) throw new IllegalStateException(); mContext = context; mSpeechController = speechController; mFullScreenReadController = fullScreenReadController; mSpeechParams.putFloat(SpeechController.SpeechParam.PITCH, DEFAULT_PITCH); mSpeechParams.putFloat(SpeechController.SpeechParam.RATE, DEFAULT_RATE); cursorController.addScrollListener(this); } @Override public void onAccessibilityEvent(AccessibilityEvent event) { if (shouldIgnoreEvent(event)) { return; } switch (event.getEventType()) { case AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED: // Window state changes clear the cache. mCachedFromValues.clear(); mCachedItemCounts.clear(); mHandler.cancelScrollFeedback(); break; case AccessibilityEvent.TYPE_VIEW_SCROLLED: case AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED: mHandler.postScrollFeedback(event); break; } } @Override public void onScroll(AccessibilityNodeInfoCompat scrolledNode, int action, boolean auto) { AccessibilityNodeInfoUtils.recycleNodes(mAutoScrollNode); if (auto) { mAutoScrollNode = AccessibilityNodeInfoCompat.obtain(scrolledNode); } else { mAutoScrollNode = null; } } private boolean shouldIgnoreEvent(AccessibilityEvent event) { switch (event.getEventType()) { case AccessibilityEventCompat.TYPE_VIEW_ACCESSIBILITY_FOCUSED: case AccessibilityEventCompat.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED: return true; case AccessibilityEventCompat.TYPE_WINDOW_CONTENT_CHANGED: case AccessibilityEventCompat.TYPE_VIEW_SCROLLED: return shouldIgnoreUpdateListEvent(event); default: return false; } } private boolean shouldIgnoreUpdateListEvent(AccessibilityEvent event) { // Don't speak during full-screen read. if (mFullScreenReadController.isActive()) { return true; } final int fromIndex = event.getFromIndex() + 1; final int itemCount = event.getItemCount(); if (itemCount <= 0 || fromIndex <= 0) { return true; } EventId eventId; try { eventId = new EventId(event); } catch (Exception e) { return true; } final Integer cachedFromIndex = mCachedFromValues.get(eventId); final Integer cachedItemCount = mCachedItemCounts.get(eventId); if ((cachedFromIndex != null) && (cachedFromIndex == fromIndex) && (cachedItemCount != null) && (cachedItemCount == itemCount)) { // The from index hasn't changed, which means the event is coming // from a re-layout or resize and should not be spoken. return true; } // The behavior of put() for an existing key is unspecified, so we can't // recycle the old or new key nodes. mCachedFromValues.put(eventId, fromIndex); mCachedItemCounts.put(eventId, itemCount); // Allow the list indices to be cached, but don't actually speak after auto-scroll. if (mAutoScrollNode != null) { AccessibilityNodeInfo source = event.getSource(); if (source != null) { try { if (source.equals(mAutoScrollNode.getInfo())) { mAutoScrollNode.recycle(); mAutoScrollNode = null; return true; } } finally { source.recycle(); } } } return false; } /** * Given an {@link AccessibilityEvent}, speaks a scroll position. * * @param event The source event. */ private void handleScrollFeedback(AccessibilityEvent event) { final CharSequence text; AccessibilityNodeInfo source = event.getSource(); if (Role.getRole(source) == Role.ROLE_PAGER) { text = getDescriptionForPageEvent(event, source); } else { text = getDescriptionForScrollEvent(event); } if (source != null) { source.recycle(); } if (TextUtils.isEmpty(text)) { return; } // don't pronounce non-visible nodes AccessibilityNodeInfo node = event.getSource(); if (node != null && !node.isVisibleToUser()) { return; } // Use QUEUE mode so that we don't interrupt more important messages. mSpeechController.speak(text, SpeechController.QUEUE_MODE_QUEUE, 0, mSpeechParams); } private CharSequence getDescriptionForScrollEvent(AccessibilityEvent event) { // If the from index or item count are invalid, don't announce anything. final int fromIndex = (event.getFromIndex() + 1); final int itemCount = event.getItemCount(); if ((fromIndex <= 0) || (itemCount <= 0)) { return null; } // If the to and from indices are the same, or if the to index is // invalid, only announce the item at the from index. final int toIndex = event.getToIndex() + 1; if ((fromIndex == toIndex) || (toIndex <= 0) || (toIndex > itemCount)) { return mContext.getString(R.string.template_scroll_from_count, fromIndex, itemCount); } // Announce the range of visible items. return mContext.getString( R.string.template_scroll_from_to_count, fromIndex, toIndex, itemCount); } private CharSequence getDescriptionForPageEvent(AccessibilityEvent event, AccessibilityNodeInfo source) { final int fromIndex = (event.getFromIndex() + 1); final int itemCount = event.getItemCount(); if ((fromIndex <= 0) || (itemCount <= 0)) { return null; } CharSequence pageTitle = getSelectedPageTitle(source); if (!TextUtils.isEmpty(pageTitle)) { CharSequence count = mContext.getString(R.string.template_viewpager_index_count_short, fromIndex, itemCount); SpannableStringBuilder output = new SpannableStringBuilder(); StringBuilderUtils.appendWithSeparator(output, pageTitle, count); return output; } return mContext.getString(R.string.template_viewpager_index_count, fromIndex, itemCount); } private static CharSequence getSelectedPageTitle(AccessibilityNodeInfo node) { // We need to refresh() after the scroll to get an accurate page title but we can only // do that on API 18+. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) { return null; } if (node == null) { return null; } AccessibilityNodeInfoCompat nodeCompat = new AccessibilityNodeInfoCompat(node); nodeCompat.refresh(); int numChildren = nodeCompat.getChildCount(); // Not the number of pages! CharSequence title = null; for (int i = 0; i < numChildren; ++i) { AccessibilityNodeInfoCompat child = nodeCompat.getChild(i); if (child != null) { try { if (child.isVisibleToUser()) { if (title == null) { // Try to roughly match RulePagerPage, which uses getNodeText // (but completely matching all the time is not critical). title = AccessibilityNodeInfoUtils.getNodeText(child); } else { // Multiple visible children, abort. return null; } } } finally { child.recycle(); } } } return title; } private static class ScrollPositionHandler extends WeakReferenceHandler<ProcessorScrollPosition> { /** Message identifier for a scroll position notification. */ private static final int SCROLL_FEEDBACK = 1; /** Delay before reading a scroll position notification. */ private static final long DELAY_SCROLL_FEEDBACK = 1000; /** Delay before reading a page position notification. */ private static final long DELAY_PAGE_FEEDBACK = 500; public ScrollPositionHandler(ProcessorScrollPosition parent) { super(parent); } @Override public void handleMessage(Message msg, ProcessorScrollPosition parent) { final AccessibilityEvent event = (AccessibilityEvent) msg.obj; switch (msg.what) { case SCROLL_FEEDBACK: parent.handleScrollFeedback(event); break; } event.recycle(); } /** * Posts the delayed scroll position feedback. Call this for every * VIEW_SCROLLED event. */ private void postScrollFeedback(AccessibilityEvent event) { cancelScrollFeedback(); final AccessibilityEvent eventClone = AccessibilityEvent.obtain(event); final Message msg = obtainMessage(SCROLL_FEEDBACK, eventClone); AccessibilityNodeInfo source = event.getSource(); if (Role.getRole(source) == Role.ROLE_PAGER) { sendMessageDelayed(msg, DELAY_PAGE_FEEDBACK); } else { sendMessageDelayed(msg, DELAY_SCROLL_FEEDBACK); } if (source != null) { source.recycle(); } } /** * Removes any pending scroll position feedback. Call this for every * event. */ private void cancelScrollFeedback() { removeMessages(SCROLL_FEEDBACK); } } private static class EventId { public long nodeId; public int windowId; private final int hashcode; private static Method sGetSourceNodeIdMethod; private static final String LOGTAG = "EventId"; static { try { sGetSourceNodeIdMethod = AccessibilityRecord.class.getDeclaredMethod("getSourceNodeId"); sGetSourceNodeIdMethod.setAccessible(true); } catch (NoSuchMethodException e) { Log.d(LOGTAG, "Error setting up fields: " + e.toString()); e.printStackTrace(); } } public EventId(long nodeId, int windowId) { this.nodeId = nodeId; this.windowId = windowId; hashcode = (int) (nodeId ^ (nodeId >>> 32)) + windowId * 7; } public EventId(AccessibilityEvent event) throws InvocationTargetException, IllegalAccessException { this((long) sGetSourceNodeIdMethod.invoke(event), event.getWindowId()); } @Override public boolean equals(Object other) { if (! (other instanceof EventId)) { return false; } EventId otherId = (EventId) other; return windowId == otherId.windowId && nodeId == otherId.nodeId; } @Override public int hashCode() { return hashcode; } } }