com.android.talkback.formatter.TouchExplorationFormatter.java Source code

Java tutorial

Introduction

Here is the source code for com.android.talkback.formatter.TouchExplorationFormatter.java

Source

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

import android.os.Build;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.os.BuildCompat;
import android.support.v4.view.accessibility.AccessibilityEventCompat;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
import android.support.v4.view.accessibility.AccessibilityRecordCompat;
import android.support.v4.view.accessibility.AccessibilityWindowInfoCompat;
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.AccessibilityWindowInfo;

import com.android.talkback.CollectionState;
import com.android.talkback.FeedbackItem;
import com.android.talkback.InputModeManager;
import com.android.talkback.R;
import com.android.talkback.SpeechController;
import com.android.talkback.eventprocessor.EventState;
import com.android.utils.Role;
import com.android.utils.StringBuilderUtils;
import com.google.android.marvin.talkback.TalkBackService;
import com.android.talkback.Utterance;
import com.android.talkback.speechrules.NodeSpeechRuleProcessor;
import com.android.utils.AccessibilityEventListener;
import com.android.utils.AccessibilityEventUtils;
import com.android.utils.AccessibilityNodeInfoUtils;
import com.android.utils.LogUtils;
import com.android.utils.traversal.SimpleTraversalStrategy;
import com.android.utils.traversal.TraversalStrategy;
import com.android.utils.WindowManager;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;

/**
 * This class is a formatter for handling touch exploration events. Current
 * implementation is simple and handles only hover enter events.
 */
public final class TouchExplorationFormatter implements EventSpeechRule.AccessibilityEventFormatter,
        EventSpeechRule.ContextBasedRule, AccessibilityEventListener {
    /** The default queuing mode for touch exploration feedback. */
    private static final int DEFAULT_QUEUING_MODE = SpeechController.QUEUE_MODE_FLUSH_ALL;

    /** The default text spoken for nodes with no description. */
    private static final CharSequence DEFAULT_DESCRIPTION = "";

    /** Whether the last region the user explored was scrollable. */
    private boolean mLastNodeWasScrollable;

    private int mLastFocusedWindowId = -1;

    /**
     * The node processor used to generate spoken descriptions. Should be set
     * only while this class is formatting an event, {@code null} otherwise.
     */
    private NodeSpeechRuleProcessor mNodeProcessor;

    private TalkBackService mService;

    private @Nullable CollectionState mCollectionState;

    private final HashMap<Integer, CharSequence> mWindowTitlesMap = new HashMap<Integer, CharSequence>();

    @Override
    public void initialize(TalkBackService service) {
        service.addEventListener(this);
        mService = service;
        mNodeProcessor = NodeSpeechRuleProcessor.getInstance();
        mCollectionState = new CollectionState();
        mLastFocusedWindowId = -1;
        mWindowTitlesMap.clear();
    }

    /**
     * Resets cached scrollable state when touch exploration after window state
     * changes.
     */
    @Override
    public void onAccessibilityEvent(AccessibilityEvent event) {
        switch (event.getEventType()) {
        case AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED:
            // Reset cached scrollable state.
            mLastNodeWasScrollable = false;

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                // Store window title in the map.
                List<CharSequence> titles = event.getText();
                if (titles.size() > 0) {
                    AccessibilityNodeInfo node = event.getSource();
                    if (node != null) {
                        int windowType = getWindowType(node);
                        if (windowType == AccessibilityWindowInfo.TYPE_APPLICATION
                                || windowType == AccessibilityWindowInfo.TYPE_SYSTEM) {
                            mWindowTitlesMap.put(node.getWindowId(), titles.get(0));
                        }
                        node.recycle();
                    }
                }
            }
            break;
        case AccessibilityEvent.TYPE_WINDOWS_CHANGED:
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                // Copy key set not to modify original map.
                HashSet<Integer> windowIdsToBeRemoved = new HashSet<Integer>();
                windowIdsToBeRemoved.addAll(mWindowTitlesMap.keySet());

                // Enumerate window ids to be removed.
                List<AccessibilityWindowInfo> windows = mService.getWindows();
                for (AccessibilityWindowInfo window : windows) {
                    windowIdsToBeRemoved.remove(window.getId());
                }

                // Delete titles of non-existing window ids.
                for (Integer windowId : windowIdsToBeRemoved) {
                    mWindowTitlesMap.remove(windowId);
                }
            }
            break;
        }
    }

    /**
     * Formatter that returns an utterance to announce touch exploration.
     */
    @Override
    public boolean format(AccessibilityEvent event, TalkBackService context, Utterance utterance) {
        if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED && EventState.getInstance()
                .checkAndClearRecentEvent(EventState.EVENT_SKIP_FOCUS_PROCESSING_AFTER_GRANULARITY_MOVE)) {
            return false;
        }
        if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED && EventState.getInstance()
                .checkAndClearRecentEvent(EventState.EVENT_SKIP_FOCUS_PROCESSING_AFTER_CURSOR_CONTROL)) {
            return false;
        }

        final AccessibilityRecordCompat record = AccessibilityEventCompat.asRecord(event);
        final AccessibilityNodeInfoCompat sourceNode = record.getSource();
        final AccessibilityNodeInfoCompat focusedNode = getFocusedNode(event.getEventType(), sourceNode);

        // Drop the event if the source node was non-null, but the focus
        // algorithm decided to drop the event by returning null.
        if ((sourceNode != null) && (focusedNode == null)) {
            AccessibilityNodeInfoUtils.recycleNodes(sourceNode);
            return false;
        }

        LogUtils.log(this, Log.VERBOSE, "Announcing node: %s", focusedNode);

        // Transition the collection state if necessary.
        mCollectionState.updateCollectionInformation(focusedNode, event);

        // Populate the utterance.
        addEarconWhenAccessibilityFocusMovesToTheDivider(utterance, focusedNode);
        addSpeechFeedback(utterance, focusedNode, event, sourceNode);
        addAuditoryHapticFeedback(utterance, focusedNode);

        // By default, touch exploration flushes all other events.
        utterance.getMetadata().putInt(Utterance.KEY_METADATA_QUEUING, DEFAULT_QUEUING_MODE);

        // Events formatted by this class should always advance continuous
        // reading, if active.
        utterance.addSpokenFlag(FeedbackItem.FLAG_ADVANCE_CONTINUOUS_READING);

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            mLastFocusedWindowId = focusedNode.getWindowId();
        }

        AccessibilityNodeInfoUtils.recycleNodes(sourceNode, focusedNode);

        return true;
    }

    private void addEarconWhenAccessibilityFocusMovesToTheDivider(Utterance utterance,
            AccessibilityNodeInfoCompat announcedNode) {
        if (!BuildCompat.isAtLeastN() || mLastFocusedWindowId == announcedNode.getWindowId()) {
            return;
        }

        // TODO: Use AccessibilityWindowInfoCompat.TYPE_SPLIT_SCREEN_DIVIDER once it's
        // added.
        if (getWindowType(announcedNode) != AccessibilityWindowInfo.TYPE_SPLIT_SCREEN_DIVIDER) {
            return;
        }

        utterance.addAuditory(R.raw.complete);
    }

    /**
     * Computes a focused node based on the device's supported APIs and the
     * event type.
     *
     * @param eventType The event type.
     * @param sourceNode The source node.
     * @return The focused node, or {@code null} to drop the event.
     */
    private AccessibilityNodeInfoCompat getFocusedNode(int eventType, AccessibilityNodeInfoCompat sourceNode) {
        if (sourceNode == null) {
            return null;
        }
        if (eventType != AccessibilityEventCompat.TYPE_VIEW_ACCESSIBILITY_FOCUSED) {
            return null;
        }
        return AccessibilityNodeInfoCompat.obtain(sourceNode);
    }

    /**
     * Populates utterance about window transition. We populate this feedback only when user is in
     * split screen mode to avoid verbosity of feedback.
     */
    private void addWindowTransition(Utterance utterance, AccessibilityNodeInfoCompat announcedNode) {
        int windowId = announcedNode.getWindowId();
        if (windowId == mLastFocusedWindowId) {
            return;
        }

        int windowType = getWindowType(announcedNode);
        if (windowType != AccessibilityWindowInfoCompat.TYPE_APPLICATION
                && windowType != AccessibilityWindowInfoCompat.TYPE_SYSTEM) {
            return;
        }

        List<AccessibilityWindowInfo> windows = mService.getWindows();
        List<AccessibilityWindowInfo> applicationWindows = new ArrayList<>();
        for (AccessibilityWindowInfo window : windows) {
            if (window.getType() == AccessibilityWindowInfo.TYPE_APPLICATION) {
                if (window.getParent() == null) {
                    applicationWindows.add(window);
                }
            }
        }

        // Provide window transition feedback only when user is in split screen mode or navigating
        // with keyboard. We consider user is in split screen mode if there are two none-parented
        // application windows.
        if (applicationWindows.size() != 2
                && mService.getInputModeManager().getInputMode() != InputModeManager.INPUT_MODE_KEYBOARD) {
            return;
        }

        WindowManager windowManager = new WindowManager(mService.isScreenLayoutRTL());
        windowManager.setWindows(windows);

        CharSequence title = null;
        if (!applicationWindows.isEmpty() && windowManager.isStatusBar(windowId)) {
            title = mService.getString(R.string.status_bar);
        } else if (!applicationWindows.isEmpty() && windowManager.isNavigationBar(windowId)) {
            title = mService.getString(R.string.navigation_bar);
        } else {
            title = mWindowTitlesMap.get(windowId);

            if (title == null && BuildCompat.isAtLeastN()) {
                for (AccessibilityWindowInfo window : windows) {
                    if (window.getId() == windowId) {
                        title = window.getTitle();
                        break;
                    }
                }
            }

            if (title == null) {
                title = mService.getApplicationLabel(announcedNode.getPackageName());
            }
        }

        int templateId = windowType == AccessibilityWindowInfo.TYPE_APPLICATION
                ? R.string.template_window_switch_application
                : R.string.template_window_switch_system;
        utterance.addSpoken(
                mService.getString(templateId, WindowManager.formatWindowTitleForFeedback(title, mService)));
    }

    /**
     * Populates an utterance with text, either from the node or event.
     *
     * @param utterance The target utterance.
     * @param announcedNode The computed announced node.
     * @param event The source event, only used to providing a description when
     *            the source node is a progress bar.
     * @param source The source node, used to determine whether the source event
     *            should be passed to the node formatter.
     * @return {@code true} if a description could be obtained for the node.
     */
    private boolean addDescription(Utterance utterance, AccessibilityNodeInfoCompat announcedNode,
            AccessibilityEvent event, AccessibilityNodeInfoCompat source) {
        // Ensure that we speak touch exploration, even during speech reco.
        utterance.addSpokenFlag(FeedbackItem.FLAG_DURING_RECO);

        final CharSequence treeDescription = mNodeProcessor.getDescriptionForTree(announcedNode, event, source);
        if (!TextUtils.isEmpty(treeDescription)) {
            utterance.addSpoken(treeDescription);
            return true;
        }

        final CharSequence eventDescription = AccessibilityEventUtils.getEventTextOrDescription(event);
        if (!TextUtils.isEmpty(eventDescription)) {
            utterance.addSpoken(eventDescription);
            return true;
        }

        // Full-screen reading requires onUtteranceCompleted to occur, which
        // requires that we always speak something when focusing an item.
        utterance.addSpoken(DEFAULT_DESCRIPTION);
        return false;
    }

    private void addCollectionTransition(Utterance utterance) {
        @CollectionState.CollectionTransition
        int collectionTransition = mCollectionState.getCollectionTransition();
        if (collectionTransition != CollectionState.NAVIGATE_ENTER
                && collectionTransition != CollectionState.NAVIGATE_EXIT) {
            return;
        }

        CharSequence transitionText;
        if (collectionTransition == CollectionState.NAVIGATE_ENTER) {
            CharSequence collectionDescription = getCollectionDescription(mCollectionState, true);
            transitionText = mService.getString(R.string.template_collection_start, collectionDescription);
        } else { // NAVIGATE_EXIT
            CharSequence collectionDescription = getCollectionDescription(mCollectionState, false);
            if (!mCollectionState.doesCollectionExist()) {
                // If the collection root no longer exists, then skip the exit announcement.
                // The app has probably switched its activity/fragment/other UI.
                LogUtils.log(this, Log.VERBOSE, "Exit announcement skipped: %s", collectionDescription);
                return;
            }

            transitionText = mService.getString(R.string.template_collection_end, collectionDescription);
        }

        utterance.addSpoken(transitionText);
    }

    private void addCollectionItemTransition(Utterance utterance, AccessibilityNodeInfoCompat announcedNode) {
        @CollectionState.RowColumnTransition
        int rowColumnTransition = mCollectionState.getRowColumnTransition();
        if (rowColumnTransition == CollectionState.TYPE_NONE) {
            return;
        }

        // Add heading label only if item has no role description, so that we don't end up
        // duplicating the role description.
        boolean hasRoleDescription = announcedNode != null && announcedNode.getRoleDescription() != null;
        if (mCollectionState.getCollectionRole() == Role.ROLE_GRID) {
            // For tables, we want to be selective with what we say since there's a lot of
            // information (e.g. row name, column name, heading).
            CollectionState.TableItemState tableItem = mCollectionState.getTableItemState();

            if (!hasRoleDescription) {
                switch (tableItem.getHeadingType()) {
                case CollectionState.TYPE_COLUMN:
                    utterance.addSpoken(mService.getString(R.string.column_heading_template));
                    break;
                case CollectionState.TYPE_ROW:
                    utterance.addSpoken(mService.getString(R.string.row_heading_template));
                    break;
                case CollectionState.TYPE_INDETERMINATE:
                    utterance.addSpoken(mService.getString(R.string.heading_template));
                    break;
                }
            }

            if ((rowColumnTransition & CollectionState.TYPE_ROW) != 0
                    && tableItem.getHeadingType() != CollectionState.TYPE_ROW && tableItem.getRowIndex() != -1) {
                if (tableItem.getRowName() != null) {
                    utterance.addSpoken(tableItem.getRowName());
                } else {
                    utterance.addSpoken(
                            mService.getString(R.string.row_index_template, tableItem.getRowIndex() + 1));
                }
            }

            if ((rowColumnTransition & CollectionState.TYPE_COLUMN) != 0
                    && tableItem.getHeadingType() != CollectionState.TYPE_COLUMN
                    && tableItem.getColumnIndex() != -1) {
                if (tableItem.getColumnName() != null) {
                    utterance.addSpoken(tableItem.getColumnName());
                } else {
                    utterance.addSpoken(
                            mService.getString(R.string.column_index_template, tableItem.getColumnIndex() + 1));
                }
            }
        } else {
            // For lists, we can just say everything since the additional feedback is limited.
            CollectionState.ListItemState listItem = mCollectionState.getListItemState();

            // Add heading label only if item has no role description.
            if (listItem.isHeading() && !hasRoleDescription) {
                utterance.addSpoken(mService.getString(R.string.heading_template));
            }
        }
    }

    /**
     * Adds speech feedback for a focused node. This speech feedback depends on both the previously
     * focused node and the currently focused node.
     *
     * @param utterance The target utterance.
     * @param announcedNode The computed announced node.
     * @param event The source event, only used to providing a description when
     *            the source node is a progress bar.
     * @param source The source node, used to determine whether the source event
     *            should be passed to the node formatter.
     */
    private void addSpeechFeedback(Utterance utterance, @Nullable AccessibilityNodeInfoCompat announcedNode,
            AccessibilityEvent event, AccessibilityNodeInfoCompat source) {
        // Ensure that we speak touch exploration, even during speech reco.
        utterance.addSpokenFlag(FeedbackItem.FLAG_DURING_RECO);

        // Add the current node's description.
        addDescription(utterance, announcedNode, event, source);

        // Append extra list information, e.g. "2 of 5".
        addCollectionItemTransition(utterance, announcedNode);

        // Determine whether we have entered a different/new collection.
        addCollectionTransition(utterance);

        // Add spoken feedback about window transition if it had happened.
        addWindowTransition(utterance, announcedNode);
    }

    /**
     * Adds auditory and haptic feedback for a focused node.
     *
     * @param utterance The utterance to which to add the earcons.
     * @param announcedNode The node that is announced.
     */
    private void addAuditoryHapticFeedback(Utterance utterance,
            @Nullable AccessibilityNodeInfoCompat announcedNode) {
        if (announcedNode == null) {
            return;
        }

        final AccessibilityNodeInfoCompat scrollableNode = AccessibilityNodeInfoUtils
                .getSelfOrMatchingAncestor(announcedNode, AccessibilityNodeInfoUtils.FILTER_SCROLLABLE);
        final boolean userCanScroll = (scrollableNode != null);

        AccessibilityNodeInfoUtils.recycleNodes(scrollableNode);

        // Announce changes in whether the user can scroll the item they are
        // touching. This includes items with scrollable parents.
        if (mLastNodeWasScrollable != userCanScroll) {
            mLastNodeWasScrollable = userCanScroll;

            if (userCanScroll) {
                utterance.addAuditory(R.raw.chime_up);
            } else {
                utterance.addAuditory(R.raw.chime_down);
            }
        }

        // If the user can scroll, also check whether this item is at the edge
        // of a list and provide feedback if the user can scroll for more items.
        // Don't run this for API < 16 because it's slow without node caching.
        AccessibilityNodeInfoCompat rootNode = AccessibilityNodeInfoUtils.getRoot(announcedNode);
        TraversalStrategy traversalStrategy = new SimpleTraversalStrategy();

        try {
            if (userCanScroll && AccessibilityNodeInfoUtils.isEdgeListItem(announcedNode, traversalStrategy)) {
                utterance.addAuditory(R.raw.scroll_more);
            }
        } finally {
            traversalStrategy.recycle();
            AccessibilityNodeInfoUtils.recycleNodes(rootNode);
        }

        // Actionable items provide different feedback than non-actionable ones.
        if (AccessibilityNodeInfoUtils.isActionableForAccessibility(announcedNode)) {
            utterance.addAuditory(R.raw.focus_actionable);
            utterance.addHaptic(R.array.view_actionable_pattern);
        } else {
            utterance.addAuditory(R.raw.focus);
            utterance.addHaptic(R.array.view_hovered_pattern);
        }
    }

    /**
     * Returns the collection's name plus its role. If {@code detailed} is true, then adds
     * the collection row/column count as well.
     * */
    private CharSequence getCollectionDescription(@NonNull CollectionState state, boolean detailed) {
        SpannableStringBuilder builder = new SpannableStringBuilder();
        StringBuilderUtils.append(builder, state.getCollectionName(), state.getCollectionRoleDescription(mService));

        if (detailed) {
            int collectionLevel = state.getCollectionLevel();
            if (collectionLevel >= 0) {
                String levelText = mService.getString(R.string.template_collection_level, collectionLevel + 1);
                StringBuilderUtils.appendWithSeparator(builder, levelText);
            }

            int rowCount = state.getCollectionRowCount();
            int columnCount = state.getCollectionColumnCount();

            if (state.getCollectionRole() == Role.ROLE_GRID && rowCount != -1 && columnCount != -1) {
                String rowText = mService.getResources().getQuantityString(R.plurals.template_list_row_count,
                        rowCount, rowCount);
                String columnText = mService.getResources().getQuantityString(R.plurals.template_list_column_count,
                        columnCount, columnCount);
                StringBuilderUtils.appendWithSeparator(builder, rowText, columnText);
            } else if (state.getCollectionRole() == Role.ROLE_LIST) {
                if (state.getCollectionAlignment() == CollectionState.ALIGNMENT_VERTICAL && rowCount != -1) {
                    String totalText = mService.getResources()
                            .getQuantityString(R.plurals.template_list_total_count, rowCount, rowCount);
                    StringBuilderUtils.appendWithSeparator(builder, totalText);
                } else if (state.getCollectionAlignment() == CollectionState.ALIGNMENT_HORIZONTAL
                        && columnCount != -1) {
                    String totalText = mService.getResources()
                            .getQuantityString(R.plurals.template_list_total_count, columnCount, columnCount);
                    StringBuilderUtils.appendWithSeparator(builder, totalText);
                }
            }
        }

        return builder;
    }

    private static int getWindowType(AccessibilityNodeInfo node) {
        if (node == null) {
            return -1;
        }

        return getWindowType(new AccessibilityNodeInfoCompat(node));
    }

    private static int getWindowType(AccessibilityNodeInfoCompat nodeCompat) {
        if (nodeCompat == null) {
            return -1;
        }

        AccessibilityWindowInfoCompat windowInfoCompat = nodeCompat.getWindow();
        if (windowInfoCompat == null) {
            return -1;
        }

        int windowType = windowInfoCompat.getType();
        windowInfoCompat.recycle();
        return windowType;
    }
}