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

Java tutorial

Introduction

Here is the source code for com.android.screenspeak.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.screenspeak.eventprocessor;

import android.content.Context;
import android.content.res.Configuration;
import android.os.Handler;
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.AccessibilityRecord;
import android.widget.EditText;
import com.android.screenspeak.CallStateMonitor;
import com.android.screenspeak.RingerModeAndScreenMonitor;
import com.google.android.marvin.screenspeak.ScreenSpeakService;
import com.android.screenspeak.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 ScreenSpeakListener mTestingListener;

    /** Event types that are allowed to interrupt radial menus. */
    // TODO(KM): 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;

    /** The minimum delay between window state change and automatic events. */
    public static final long DELAY_AUTO_AFTER_STATE = 100;

    private final ScreenSpeakService mService;
    private AccessibilityManager mAccessibilityManager;
    private CallStateMonitor mCallStateMonitor;
    private ProcessorEventQueue mProcessorEventQueue;
    private RingerModeAndScreenMonitor mRingerModeAndScreenMonitor;

    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 boolean mSpeakWhenScreenOff = false;
    private boolean mSpeakCallerId = false;

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

        mService = service;
    }

    public void setSpeakCallerId(boolean speak) {
        mSpeakCallerId = speak;
    }

    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 onAccessibilityEvent(AccessibilityEvent event) {
        if (mTestingListener != null) {
            mTestingListener.onAccessibilityEvent(event);
        }

        // Chrome clears and set a11y focus for each scroll event, it is not intended to be spoken
        // to the user. Remove this when chromium is fixed.
        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) {
                        // something strange. not accessibility focused node sends clear focus event
                        // b/22108305
                        mLastClearedSourceId = -1;
                        mLastClearedWindowId = -1;
                        mLastClearA11yFocus = 0;
                    }
                } catch (Exception e) {
                    Log.d(LOGTAG, "Exception accessing field: " + e.toString());
                }
            }

            return;
        }

        if (eventType == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED) {
            if (System.currentTimeMillis() - mLastClearA11yFocus < CLEAR_SET_A11Y_FOCUS_WINDOW) {
                if (sGetSourceNodeIdMethod != null) {
                    try {
                        long sourceId = (long) sGetSourceNodeIdMethod.invoke(event);
                        int windowId = event.getWindowId();
                        if (sourceId == mLastClearedSourceId && windowId == mLastClearedWindowId) {
                            return;
                        }
                        mLastPronouncedSourceId = sourceId;
                        mLastPronouncedWindowId = windowId;
                    } catch (Exception e) {
                        Log.d(LOGTAG, "Exception accessing field: " + e.toString());
                    }
                }
            }
        }

        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);
        }

        processEvent(event);
    }

    /**
     * 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 (!ScreenSpeakService.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;
        }

        // 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 shouldSpeakCallerId = (mSpeakCallerId && (mCallStateMonitor != null)
                && (mCallStateMonitor.getCurrentCallState() == TelephonyManager.CALL_STATE_RINGING));

        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 (AccessibilityNodeInfoUtils.nodeMatchesClassByType(node, EditText.class)) {
                    return true;
                }
            } finally {
                AccessibilityNodeInfoUtils.recycleNodes(node);
            }
        }

        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(ScreenSpeakListener testingListener) {
        mTestingListener = testingListener;

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

    public ScreenSpeakListener getTestingListener() {
        return mTestingListener;
    }

    public interface ScreenSpeakListener {
        void onAccessibilityEvent(AccessibilityEvent event);

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