/* * 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.speechrules; import android.annotation.TargetApi; import android.os.Build; import android.text.Spannable; import android.view.accessibility.AccessibilityNodeInfo; import com.android.talkback.R; import android.content.Context; 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 com.android.utils.AccessibilityNodeInfoUtils; import com.android.utils.LogUtils; import com.android.utils.Role; import com.android.utils.StringBuilderUtils; import com.android.utils.traversal.ReorderedChildrenIterator; import java.util.HashSet; import java.util.LinkedList; import java.util.Set; /** * Rule-based processor for {@link AccessibilityNodeInfoCompat}s. */ public class NodeSpeechRuleProcessor { private static final LinkedList<NodeSpeechRule> mRules = new LinkedList<>(); private static final RuleSwitch mRuleSwitch = new RuleSwitch(); private static NodeSpeechRuleProcessor sInstance; static { // Rules are matched in the order they are added, so make sure to place // general rules after specific ones (e.g. Button after RadioButton). mRules.add(new RuleSimpleHintTemplate(Role.ROLE_DROP_DOWN_LIST, R.string.template_hint_spinner, R.string.template_hint_spinner_keyboard)); mRules.add(mRuleSwitch); mRules.add(new RuleNonTextViews()); // ImageViews and ImageButtons mRules.add(new RuleEditText()); mRules.add(new RuleSeekBar()); mRules.add(new RulePager()); mRules.add(new RulePagerPage()); mRules.add(new RuleContainer()); mRules.add(new RuleViewGroup()); // Always add the default rule last. mRules.add(new RuleDefault()); } // TODO: remove this public static void initialize(Context context) { sInstance = new NodeSpeechRuleProcessor(context); } // TODO: remove this public static NodeSpeechRuleProcessor getInstance() { if (sInstance == null) { throw new RuntimeException("NodeSpeechRuleProcessor not initialized"); } return sInstance; } /** The parent context. */ private final Context mContext; private NodeSpeechRuleProcessor(Context context) { mContext = context; } /** * Returns the best description for the subtree rooted at * {@code announcedNode}. * * @param announcedNode The root node of the subtree to describe. * @param event The source event, may be {@code null} when called with * non-source nodes. * @param source The event's source node. * @return The best description for a node. */ public CharSequence getDescriptionForTree(AccessibilityNodeInfoCompat announcedNode, AccessibilityEvent event, AccessibilityNodeInfoCompat source) { if (announcedNode == null) { return null; } final SpannableStringBuilder builder = new SpannableStringBuilder(); Set<AccessibilityNodeInfoCompat> visitedNodes = new HashSet<>(); appendDescriptionForTree(announcedNode, builder, event, source, visitedNodes); AccessibilityNodeInfoUtils.recycleNodes(visitedNodes); formatTextWithLabel(announcedNode, builder); appendRootMetadataToBuilder(announcedNode, builder); return builder; } /** * Returns hint text for a node. * * @param node The node to provide hint text for. * @return The node's hint text. */ public CharSequence getHintForNode(AccessibilityNodeInfoCompat node) { // Disabled items don't have any hint text. if (!node.isEnabled()) { return null; } for (NodeSpeechRule rule : mRules) { if ((rule instanceof NodeHintRule) && rule.accept(node, null)) { LogUtils.log(this, Log.VERBOSE, "Processing node hint using %s", rule); return ((NodeHintRule) rule).getHintText(mContext, node); } } return null; } private void appendDescriptionForTree(AccessibilityNodeInfoCompat announcedNode, SpannableStringBuilder builder, AccessibilityEvent event, AccessibilityNodeInfoCompat source, Set<AccessibilityNodeInfoCompat> visitedNodes) { if (announcedNode == null) { return; } AccessibilityNodeInfoCompat visitedNode = AccessibilityNodeInfoCompat.obtain(announcedNode); if (!visitedNodes.add(visitedNode)) { visitedNode.recycle(); return; } final AccessibilityEvent nodeEvent = (announcedNode.equals(source)) ? event : null; final CharSequence nodeDesc = getDescriptionForNode(announcedNode, nodeEvent); final boolean blockChildDescription = hasOverridingContentDescription(announcedNode); SpannableStringBuilder childStringBuilder = new SpannableStringBuilder(); if (!blockChildDescription) { // Recursively append descriptions for visible and non-focusable child nodes. ReorderedChildrenIterator iterator = ReorderedChildrenIterator .createAscendingIterator(announcedNode); while (iterator.hasNext()) { AccessibilityNodeInfoCompat child = iterator.next(); if (AccessibilityNodeInfoUtils.isVisible(child) && !AccessibilityNodeInfoUtils.isAccessibilityFocusable(child)) { appendDescriptionForTree(child, childStringBuilder, event, source, visitedNodes); } } iterator.recycle(); } // If any one of the following is satisfied: // 1. The root node has a description. // 2. The root has no override content description and the children have some description. // Then we should append the status information for this node. // This is used to avoid displaying checked/expanded status alone without node description. // if (!TextUtils.isEmpty(nodeDesc) || !TextUtils.isEmpty(childStringBuilder)) { appendExpandedOrCollapsedStatus(announcedNode, event, builder); appendCheckedStatus(announcedNode, event, builder); } StringBuilderUtils.appendWithSeparator(builder, nodeDesc); StringBuilderUtils.appendWithSeparator(builder, childStringBuilder); } /** * Determines whether the node has a contentDescription that should cause its subtree's * description to be ignored. * The traditional behavior in TalkBack has been to return {@code true} for any node with a * non-empty contentDescription. In this function, we thus whitelist certain roles where it * doesn't make sense for the contentDescription to override the entire subtree. */ private static boolean hasOverridingContentDescription(AccessibilityNodeInfoCompat node) { switch (Role.getRole(node)) { case Role.ROLE_PAGER: case Role.ROLE_GRID: case Role.ROLE_LIST: return false; default: return node != null && !TextUtils.isEmpty(node.getContentDescription()); } } /** * Processes the specified node using a series of speech rules. * * @param node The node to process. * @param event The source event, may be {@code null} when called with * non-source nodes. * @return A string representing the given node, or {@code null} if the node * could not be processed. */ public CharSequence getDescriptionForNode( AccessibilityNodeInfoCompat node, AccessibilityEvent event) { for (NodeSpeechRule rule : mRules) { if (rule.accept(node, event)) { LogUtils.log(this, Log.VERBOSE, "Processing node using %s", rule); return rule.format(mContext, node, event); } } return null; } /** * If the supplied node has a label, replaces the builder text with a * version formatted with the label. */ @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) private void formatTextWithLabel( AccessibilityNodeInfoCompat node, SpannableStringBuilder builder) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) return; // TODO: add getLabeledBy to support lib AccessibilityNodeInfo info = (AccessibilityNodeInfo) node.getInfo(); if (info == null) return; AccessibilityNodeInfo labeledBy = info.getLabeledBy(); if (labeledBy == null) return; AccessibilityNodeInfoCompat labelNode = new AccessibilityNodeInfoCompat(labeledBy); final SpannableStringBuilder labelDescription = new SpannableStringBuilder(); Set<AccessibilityNodeInfoCompat> visitedNodes = new HashSet<>(); appendDescriptionForTree(labelNode, labelDescription, null, null, visitedNodes); AccessibilityNodeInfoUtils.recycleNodes(visitedNodes); if (TextUtils.isEmpty(labelDescription)) { return; } final String labeled = mContext.getString( R.string.template_labeled_item, builder, labelDescription); Spannable spannableLabeledText = StringBuilderUtils.createSpannableFromTextWithTemplate( labeled, builder); // Replace the text of the builder. builder.clear(); builder.append(spannableLabeledText); } /** * Appends meta-data about node's disabled state (if actionable). * <p> * This should only be applied to the root node of a tree. */ private void appendRootMetadataToBuilder( AccessibilityNodeInfoCompat node, SpannableStringBuilder descriptionBuilder) { // Append state for actionable but disabled nodes. if (AccessibilityNodeInfoUtils.isActionableForAccessibility(node) && !node.isEnabled()) { StringBuilderUtils.appendWithSeparator( descriptionBuilder, mContext.getString(R.string.value_disabled)); } // Append the control's selected state. if (node.isSelected()) { StringBuilderUtils.appendWithSeparator(descriptionBuilder, mContext.getString(R.string.value_selected)); } } /** * Appends meta-data about the node's checked (if checkable) states. * <p> * This should be applied to all nodes in a tree, including the root. */ private void appendCheckedStatus(AccessibilityNodeInfoCompat node, AccessibilityEvent event, SpannableStringBuilder descriptionBuilder) { // Append the control's checked state, if applicable. Ignore nodes that // were accepted by the switch rule, since they already include the // checkable state. if (node.isCheckable() && !mRuleSwitch.accept(node, event)) { CharSequence checkedString = mContext.getString( node.isChecked() ? R.string.value_checked : R.string.value_not_checked); StringBuilderUtils.appendWithSeparator(descriptionBuilder, checkedString); } } /** * Appends meta-data about the node's expandable/collapsible states. * <p> * This should be applied to all nodes in a tree, including the root. */ private void appendExpandedOrCollapsedStatus(AccessibilityNodeInfoCompat node, AccessibilityEvent event, SpannableStringBuilder descriptionBuilder) { // Append the control's expandable/collapsible state, if applicable. if (AccessibilityNodeInfoUtils.isExpandable(node)) { CharSequence collapsedString = mContext.getString( R.string.value_collapsed); StringBuilderUtils.appendWithSeparator(descriptionBuilder, collapsedString); } if (AccessibilityNodeInfoUtils.isCollapsible(node)) { CharSequence expandedString = mContext.getString( R.string.value_expanded); StringBuilderUtils.appendWithSeparator(descriptionBuilder, expandedString); } } }