com.android.talkback.eventprocessor.AccessibilityEventProcessor.java Source code

Java tutorial

Introduction

Here is the source code for com.android.talkback.eventprocessor.AccessibilityEventProcessor.java

Source

/*
 * 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.talkback.eventprocessor;

import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.Configuration;
import android.os.Build;
import android.os.Handler;
import android.os.Message;
import android.os.SystemClock;
import android.support.v4.view.accessibility.AccessibilityEventCompat;
import android.support.v4.view.accessibility.AccessibilityManagerCompat;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
import android.support.v4.view.accessibility.AccessibilityRecordCompat;
import android.telephony.TelephonyManager;
import android.util.Log;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityManager;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityRecord;
import com.android.talkback.CallStateMonitor;
import com.android.talkback.R;
import com.android.talkback.RingerModeAndScreenMonitor;
import com.android.talkback.formatter.ClickFormatter;
import com.android.utils.Role;
import com.android.utils.SharedPreferencesUtils;
import com.google.android.marvin.talkback.TalkBackService;
import com.android.talkback.Utterance;
import com.android.utils.AccessibilityEventListener;
import com.android.utils.AccessibilityEventUtils;
import com.android.utils.AccessibilityNodeInfoUtils;
import com.android.utils.LogUtils;

import java.lang.reflect.Method;
import java.util.LinkedList;
import java.util.List;

public class AccessibilityEventProcessor {
    private static final String LOGTAG = "A11yEventProcessor";
    private static final String DUMP_EVNET_LOG_TAG = "EventDumper";
    private TalkBackListener mTestingListener;

    /** Event types that are allowed to interrupt radial menus. */
    // TODO: What's the rationale for HOVER_ENTER? Navigation bar?
    private static final int MASK_EVENT_TYPES_INTERRUPT_RADIAL_MENU = AccessibilityEventCompat.TYPE_VIEW_HOVER_ENTER
            | AccessibilityEventCompat.TYPE_VIEW_ACCESSIBILITY_FOCUSED
            | AccessibilityEventCompat.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY;

    /** Event types to drop after receiving a window state change. */
    public static final int AUTOMATIC_AFTER_STATE_CHANGE = AccessibilityEvent.TYPE_VIEW_FOCUSED
            | AccessibilityEvent.TYPE_VIEW_SELECTED | AccessibilityEventCompat.TYPE_VIEW_SCROLLED;

    /**
     * Event types that signal a change in touch interaction state and should be
     * dropped on {@link Configuration#TOUCHSCREEN_NOTOUCH} devices
     */
    private static final int MASK_EVENT_TYPES_TOUCH_STATE_CHANGES = AccessibilityEventCompat.TYPE_GESTURE_DETECTION_START
            | AccessibilityEventCompat.TYPE_GESTURE_DETECTION_END
            | AccessibilityEventCompat.TYPE_TOUCH_EXPLORATION_GESTURE_START
            | AccessibilityEventCompat.TYPE_TOUCH_EXPLORATION_GESTURE_END
            | AccessibilityEventCompat.TYPE_TOUCH_INTERACTION_START
            | AccessibilityEventCompat.TYPE_TOUCH_INTERACTION_END | AccessibilityEventCompat.TYPE_VIEW_HOVER_ENTER
            | AccessibilityEventCompat.TYPE_VIEW_HOVER_EXIT;

    /**
     * Event types that should be processed with a very minor delay in order to wait for state to
     * catch up. The delay time is specified by {@link #EVENT_PROCESSING_DELAY}.
     *
     * Note: On Lollipop, the {@link ClickFormatter} is used and needs a short processing delay.
     * On KitKat, the legacy {@link com.android.talkback.formatter.CheckableClickedFormatter} is
     * used and needs no delay.
     */
    private static final int MASK_DELAYED_EVENT_TYPES = Build.VERSION.SDK_INT >= ClickFormatter.MIN_API_LEVEL
            ? AccessibilityEvent.TYPE_VIEW_CLICKED
            : 0;

    /**
     * The minimum delay between window state change and automatic events. Note that this delay
     * doesn't affect response to user actions, so it is OK if it is a tad long.
     */
    public static final long DELAY_AUTO_AFTER_STATE = 200;

    /**
     * The minimum delay after a focus event that a selected event can be processed on the same
     * branch of the accessibility node tree. Note that this delay doesn't affect response to user
     * actions, so it is OK if it is a tad long.
     */
    public static final long DELAY_SELECTED_AFTER_FOCUS = 200;

    /**
     * Delay (ms) to wait for the state to catch up before processing events that match the mask
     * {@link #MASK_DELAYED_EVENT_TYPES}. This delay should be nearly imperceptible; practical
     * testing has determined that the minimum delay is ~150ms, but a 150ms delay should be barely
     * perceptible. The 150ms delay has been tested on a variety of Nexus/non-Nexus devices.
     */
    public static final long EVENT_PROCESSING_DELAY = 150;

    static final String CLASS_DIALER_JELLY_BEAN = "com.android.phone.InCallScreen";
    static final String CLASS_DIALER_KITKAT = "com.android.incallui.InCallActivity";

    private final TalkBackService mService;
    private AccessibilityManager mAccessibilityManager;
    private CallStateMonitor mCallStateMonitor;
    private ProcessorEventQueue mProcessorEventQueue;
    private ProcessorFocusAndSingleTap mProcessorFocusAndSingleTap;
    private RingerModeAndScreenMonitor mRingerModeAndScreenMonitor;
    private DelayedEventHandler mHandler = new DelayedEventHandler();

    private static Method sGetSourceNodeIdMethod;

    private long mLastClearedSourceId = -1;
    private int mLastClearedWindowId = -1;
    private long mLastClearA11yFocus = System.currentTimeMillis();
    private long mLastPronouncedSourceId = -1;
    private int mLastPronouncedWindowId = -1;

    // If the same node is cleared and set inside this time we ignore the events
    private static final long CLEAR_SET_A11Y_FOCUS_WINDOW = 1000;

    static {
        try {
            sGetSourceNodeIdMethod = AccessibilityRecord.class.getDeclaredMethod("getSourceNodeId");
            sGetSourceNodeIdMethod.setAccessible(true);
        } catch (NoSuchMethodException e) {
            Log.d(LOGTAG, "Error setting up fields: " + e.toString());
            e.printStackTrace();
        }
    }

    /**
     * List of passive event processors. All processors in the list are sent the
     * event in the order they were added.
     */
    private List<AccessibilityEventListener> mAccessibilityEventListeners = new LinkedList<>();

    private boolean mIsUserTouchExploring;
    private long mLastWindowStateChanged;
    private AccessibilityEvent mLastFocusedEvent;

    private boolean mSpeakWhenScreenOff = false;

    // Use bit mask to note what types of accessibility events should dump.
    private int mDumpEventMask = 0;

    public AccessibilityEventProcessor(TalkBackService service) {
        mAccessibilityManager = (AccessibilityManager) service.getSystemService(Context.ACCESSIBILITY_SERVICE);

        mService = service;
        initDumpEventMask();
    }

    /**
     * Read dump event configuration from preferences.
     */
    private void initDumpEventMask() {
        int[] eventTypes = AccessibilityEventUtils.getAllEventTypes();
        SharedPreferences sharedPreferences = SharedPreferencesUtils.getSharedPreferences(mService);
        for (int type : eventTypes) {
            String prefKey = mService.getString(R.string.pref_dump_event_key_prefix, type);
            if (sharedPreferences.getBoolean(prefKey, false)) {
                mDumpEventMask |= type;
            }
        }
    }

    public void setSpeakWhenScreenOff(boolean speak) {
        mSpeakWhenScreenOff = speak;
    }

    public void setCallStateMonitor(CallStateMonitor callStateMonitor) {
        mCallStateMonitor = callStateMonitor;
    }

    public void setRingerModeAndScreenMonitor(RingerModeAndScreenMonitor ringerModeAndScreenMonitor) {
        mRingerModeAndScreenMonitor = ringerModeAndScreenMonitor;
    }

    public void setProcessorEventQueue(ProcessorEventQueue processorEventQueue) {
        mProcessorEventQueue = processorEventQueue;
    }

    public void setProcessorFocusAndSingleTap(ProcessorFocusAndSingleTap processor) {
        mProcessorFocusAndSingleTap = processor;
    }

    public void onAccessibilityEvent(AccessibilityEvent event) {
        if (mTestingListener != null) {
            mTestingListener.onAccessibilityEvent(event);
        }

        if ((mDumpEventMask & event.getEventType()) != 0) {
            Log.v(DUMP_EVNET_LOG_TAG, event.toString());
        }

        if (shouldDropRefocusEvent(event)) {
            return;
        }

        if (shouldDropEvent(event)) {
            return;
        }

        maintainExplorationState(event);

        if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED
                || event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED
                || event.getEventType() == AccessibilityEvent.TYPE_WINDOWS_CHANGED) {
            mService.setRootDirty(true);
        }

        // We need to save the last focused event so that we can filter out related selected events.
        if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_FOCUSED) {
            if (mLastFocusedEvent != null) {
                mLastFocusedEvent.recycle();
            }

            mLastFocusedEvent = AccessibilityEvent.obtain(event);
        }

        if (AccessibilityEventUtils.eventMatchesAnyType(event, MASK_DELAYED_EVENT_TYPES)) {
            mHandler.postProcessEvent(event);
        } else {
            processEvent(event);
        }

        if (mTestingListener != null) {
            mTestingListener.afterAccessibilityEvent(event);
        }
    }

    /**
     * Returns whether the device should drop this event due to refocus issue.
     * Sometimes TalkBack will receive four consecutive events from one single node:.
     * 1. Accessibility_Focus_Cleared
     * 2. Accessibility_Focused
     * 3. Accessibility_Focus_Cleared
     * 4. Accessibility_Focused
     * <p/>
     * The cause of this issue could be:
     * i. Chrome clears and set a11y focus for each scroll event.
     * If it is an action to navigate to previous/next element and causes view scrolling. The
     * first two events are caused by navigation, and the last two events are caused by chrome
     * refocus issue. The last two events are not intended to be spoken.
     * If it is a scroll action. It might cause a lot of a11y_focus_cleared and a11y_focused
     * events. In this case all the events are not intended to be spoken.
     * <p/>
     * ii. User taps on screen to refocus on the a11y focused node. In this case event 2 and 4
     * should be spoken to the user.
     *
     * @param event The current event.
     * @return {@code true} if the event should be dropped.
     */
    private boolean shouldDropRefocusEvent(AccessibilityEvent event) {
        int eventType = event.getEventType();
        if (eventType == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED) {
            if (sGetSourceNodeIdMethod != null) {
                try {
                    mLastClearedSourceId = (long) sGetSourceNodeIdMethod.invoke(event);
                    mLastClearedWindowId = event.getWindowId();
                    mLastClearA11yFocus = System.currentTimeMillis();
                    if (mLastClearedSourceId != mLastPronouncedSourceId
                            || mLastClearedWindowId != mLastPronouncedWindowId
                            || mProcessorFocusAndSingleTap.isFromRefocusAction(event)) {
                        // something strange. not accessibility focused node sends clear focus event
                        // BUG
                        mLastClearedSourceId = -1;
                        mLastClearedWindowId = -1;
                        mLastClearA11yFocus = 0;
                    }
                } catch (Exception e) {
                    Log.d(LOGTAG, "Exception accessing field: " + e.toString());
                }
            }
            return true;
        }

        if (eventType == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED) {
            if (sGetSourceNodeIdMethod != null
                    && !EventState.getInstance().checkAndClearRecentEvent(EventState.EVENT_NODE_REFOCUSED)) {
                try {
                    long sourceId = (long) sGetSourceNodeIdMethod.invoke(event);
                    int windowId = event.getWindowId();
                    // If this event is fired by the "clear and set a11y focus" issue of Chrome,
                    // ignore and don't speak to the user, otherwise update the node and window IDs
                    // and then process the event.
                    if (System.currentTimeMillis() - mLastClearA11yFocus < CLEAR_SET_A11Y_FOCUS_WINDOW
                            && sourceId == mLastClearedSourceId && windowId == mLastClearedWindowId) {
                        return true;
                    } else {
                        mLastPronouncedSourceId = sourceId;
                        mLastPronouncedWindowId = windowId;
                    }
                } catch (Exception e) {
                    Log.d(LOGTAG, "Exception accessing field: " + e.toString());
                }
            }
        }
        return false;
    }

    /**
     * Returns whether the device should drop this event. Caches notifications
     * if necessary.
     *
     * @param event The current event.
     * @return {@code true} if the event should be dropped.
     */
    private boolean shouldDropEvent(AccessibilityEvent event) {
        // Always drop null events.
        if (event == null) {
            return true;
        }

        // Always drop events if the service is suspended.
        if (!TalkBackService.isServiceActive()) {
            return true;
        }

        // If touch exploration is enabled, drop automatically generated events
        // that are sent immediately after a window state change... unless we
        // decide to keep the event.
        if (AccessibilityManagerCompat.isTouchExplorationEnabled(mAccessibilityManager)
                && ((event.getEventType() & AUTOMATIC_AFTER_STATE_CHANGE) != 0)
                && ((event.getEventTime() - mLastWindowStateChanged) < DELAY_AUTO_AFTER_STATE)
                && !shouldKeepAutomaticEvent(event)) {
            if (LogUtils.LOG_LEVEL <= Log.VERBOSE) {
                Log.v(LOGTAG, "Drop event after window state change");
            }
            return true;
        }

        // Some view-selected events are spurious if sent immediately after a focused event.
        if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_SELECTED && !shouldKeepViewSelectedEvent(event)) {
            if (LogUtils.LOG_LEVEL <= Log.VERBOSE) {
                Log.v(LOGTAG, "Drop selected event after focused event");
            }
            return true;
        }

        // Real notification events always have parcelable data.
        final boolean isNotification = (event.getEventType() == AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED)
                && (event.getParcelableData() != null);

        final boolean isPhoneActive = (mCallStateMonitor != null)
                && (mCallStateMonitor.getCurrentCallState() != TelephonyManager.CALL_STATE_IDLE);
        final boolean isPhoneRinging = (mCallStateMonitor != null)
                && (mCallStateMonitor.getCurrentCallState() == TelephonyManager.CALL_STATE_RINGING);

        // Sometimes the dialer's window-state-changed event gets sent right before the
        // TelephonyManager transitions to CALL_STATE_RINGING, so we need to check isDialerEvent().
        final boolean shouldSpeakCallerId = isPhoneRinging || isDialerEvent(event);

        if (mRingerModeAndScreenMonitor != null && !mRingerModeAndScreenMonitor.isScreenOn()
                && !shouldSpeakCallerId) {
            if (!mSpeakWhenScreenOff) {
                // If the user doesn't allow speech when the screen is
                // off, drop the event immediately.
                if (LogUtils.LOG_LEVEL <= Log.VERBOSE) {
                    Log.v(LOGTAG, "Drop event due to screen state and user pref");
                }
                return true;
            } else if (!isNotification) {
                // If the user allows speech when the screen is off, drop
                // all non-notification events.
                if (LogUtils.LOG_LEVEL <= Log.VERBOSE) {
                    Log.v(LOGTAG, "Drop non-notification event due to screen state");
                }
                return true;
            }
        }

        final boolean canInterruptRadialMenu = AccessibilityEventUtils.eventMatchesAnyType(event,
                MASK_EVENT_TYPES_INTERRUPT_RADIAL_MENU);
        final boolean silencedByRadialMenu = (mService.getMenuManager().isMenuShowing() && !canInterruptRadialMenu);

        // Don't speak events that cannot interrupt the radial menu, if showing
        if (silencedByRadialMenu) {
            if (LogUtils.LOG_LEVEL <= Log.VERBOSE) {
                Log.v(LOGTAG, "Drop event due to radial menu state");
            }
            return true;
        }

        // Don't speak notification events if the user is touch exploring or a phone call is active.
        if (isNotification && (mIsUserTouchExploring || isPhoneActive)) {
            if (LogUtils.LOG_LEVEL <= Log.VERBOSE) {
                Log.v(LOGTAG, "Drop notification due to touch or phone state");
            }
            return true;
        }

        final int touchscreenState = mService.getResources().getConfiguration().touchscreen;
        final boolean isTouchInteractionStateChange = AccessibilityEventUtils.eventMatchesAnyType(event,
                MASK_EVENT_TYPES_TOUCH_STATE_CHANGES);

        // Drop all events related to touch interaction state on devices that don't support touch.
        return (touchscreenState == Configuration.TOUCHSCREEN_NOTOUCH) && isTouchInteractionStateChange;
    }

    /**
     * Helper method for {@link #shouldDropEvent} that handles events that
     * automatically occur immediately after a window state change.
     *
     * @param event The automatically generated event to consider retaining.
     * @return Whether to retain the event.
     */
    private boolean shouldKeepAutomaticEvent(AccessibilityEvent event) {
        final AccessibilityRecordCompat record = AccessibilityEventCompat.asRecord(event);

        // Don't drop focus events from EditTexts.
        if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_FOCUSED) {
            AccessibilityNodeInfoCompat node = null;

            try {
                node = record.getSource();
                if (Role.getRole(node) == Role.ROLE_EDIT_TEXT) {
                    return true;
                }
            } finally {
                AccessibilityNodeInfoUtils.recycleNodes(node);
            }
        }

        return false;
    }

    /**
     * Helper method for {@link #shouldDropEvent} that filters out selected events that occur
     * in close proximity to focused events.
     *
     * A selected event should be kept if:
     * - The most recent focused event occurred over {@link #DELAY_SELECTED_AFTER_FOCUS} ms ago.
     * - The most recent focused event occurred on a different branch of the accessibility node
     *   tree, i.e., not in an ancestor or descendant of the selected event.
     *
     * @param event The view-selected event to consider retaining.
     * @return Whether to retain the event.
     */
    private boolean shouldKeepViewSelectedEvent(final AccessibilityEvent event) {
        if (mLastFocusedEvent == null) {
            return true;
        }

        if (event.getEventTime() - mLastFocusedEvent.getEventTime() > DELAY_SELECTED_AFTER_FOCUS) {
            return true;
        }

        // AccessibilityEvent.getSource will obtain() an AccessibilityNodeInfo, so it is our
        // responsibility to recycle() it.
        AccessibilityNodeInfo selectedSource = event.getSource();
        AccessibilityNodeInfo focusedSource = mLastFocusedEvent.getSource();

        try {
            // Note: AccessibilityNodeInfoCompat constructor will silently succeed when wrapping
            // a null object.
            if (selectedSource != null && focusedSource != null) {
                AccessibilityNodeInfoCompat selectedSourceCompat = new AccessibilityNodeInfoCompat(selectedSource);
                AccessibilityNodeInfoCompat focusedSourceCompat = new AccessibilityNodeInfoCompat(focusedSource);

                if (AccessibilityNodeInfoUtils.areInSameBranch(selectedSourceCompat, focusedSourceCompat)) {
                    return false;
                }
            }

            // In different branch (or we could not check branches of accessibility node tree).
            return true;
        } finally {
            if (selectedSource != null) {
                selectedSource.recycle();
            }
            if (focusedSource != null) {
                focusedSource.recycle();
            }
        }
    }

    /**
     * Helper method for {@link #shouldDropEvent} to determine whether an event is the phone dialer
     * appearing for an incoming call.
     *
     * @param event The event to check.
     * @return Whether the event represents an incoming call on the phone dialer.
     */
    private boolean isDialerEvent(final AccessibilityEvent event) {
        if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT
                    && CLASS_DIALER_JELLY_BEAN.equals(event.getClassName())) {
                return true;
            } else if (CLASS_DIALER_KITKAT.equals(event.getClassName())) {
                return true;
            }
        }

        return false;
    }

    /**
     * Manages touch exploration state.
     *
     * @param event The current event.
     */
    private void maintainExplorationState(AccessibilityEvent event) {
        final int eventType = event.getEventType();

        if (eventType == AccessibilityEventCompat.TYPE_TOUCH_EXPLORATION_GESTURE_START) {
            mIsUserTouchExploring = true;
        } else if (eventType == AccessibilityEventCompat.TYPE_TOUCH_EXPLORATION_GESTURE_END) {
            mIsUserTouchExploring = false;
        } else if (eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
            mLastWindowStateChanged = SystemClock.uptimeMillis();
        }
    }

    /**
     * Passes the event to all registered {@link AccessibilityEventListener}s in the order
     * they were added.
     *
     * @param event The current event.
     */
    private void processEvent(AccessibilityEvent event) {
        for (AccessibilityEventListener eventProcessor : mAccessibilityEventListeners) {
            eventProcessor.onAccessibilityEvent(event);
        }
    }

    public void addAccessibilityEventListener(AccessibilityEventListener listener) {
        mAccessibilityEventListeners.add(listener);
    }

    public void postRemoveAccessibilityEventListener(final AccessibilityEventListener listener) {
        new Handler().post(new Runnable() {
            @Override
            public void run() {
                mAccessibilityEventListeners.remove(listener);
            }
        });
    }

    public void setTestingListener(TalkBackListener testingListener) {
        mTestingListener = testingListener;

        if (mProcessorEventQueue != null) {
            mProcessorEventQueue.setTestingListener(testingListener);
        }
    }

    public TalkBackListener getTestingListener() {
        return mTestingListener;
    }

    /**
     * Update the dump event mask when relavant preferences are changed.
     */
    public void onDumpEventPreferenceChanged(int eventType, boolean shouldDump) {
        if (((mDumpEventMask & eventType) != 0) != shouldDump) {
            mDumpEventMask ^= eventType;
        }
    }

    public interface TalkBackListener {
        void onAccessibilityEvent(AccessibilityEvent event);

        void afterAccessibilityEvent(AccessibilityEvent event);

        // TODO: Solve this by making a fake tts and look for calls into it instead
        void onUtteranceQueued(Utterance utterance);
    }

    private class DelayedEventHandler extends Handler {

        public static final int MESSAGE_WHAT_PROCESS_EVENT = 1;

        @Override
        public void handleMessage(Message message) {
            if (message.what != MESSAGE_WHAT_PROCESS_EVENT || message.obj == null) {
                return;
            }

            AccessibilityEvent event = (AccessibilityEvent) message.obj;
            processEvent(event);
            event.recycle();
        }

        public void postProcessEvent(AccessibilityEvent event) {
            AccessibilityEvent eventCopy = AccessibilityEvent.obtain(event);
            Message msg = obtainMessage(MESSAGE_WHAT_PROCESS_EVENT, eventCopy);
            sendMessageDelayed(msg, EVENT_PROCESSING_DELAY);
        }

    }
}