/*
* 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.formatter;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.os.Build;
import android.os.Bundle;
import android.support.annotation.IntDef;
import android.support.v4.view.accessibility.AccessibilityEventCompat;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
import android.support.v4.view.accessibility.AccessibilityRecordCompat;
import android.text.TextUtils;
import android.util.Log;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityNodeInfo;
import com.android.talkback.EditTextActionHistory;
import com.android.talkback.FeedbackItem;
import com.android.talkback.R;
import com.android.talkback.SpeechCleanupUtils;
import com.android.talkback.SpeechController;
import com.google.android.marvin.talkback.TalkBackService;
import com.android.talkback.Utterance;
import com.android.talkback.controller.TextCursorController;
import com.android.utils.AccessibilityEventUtils;
import com.android.utils.LogUtils;
import com.android.utils.SharedPreferencesUtils;
import com.android.utils.compat.provider.SettingsCompatUtils;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.List;
/**
* This class contains custom formatters for presenting text edits.
*/
public final class TextFormatters {
/**
* Default pitch adjustment for text added event feedback.
*/
private static final float DEFAULT_ADD_PITCH = 1.2f;
/**
* Default pitch adjustment for text removed event feedback.
*/
private static final float DEFAULT_REMOVE_PITCH = 1.2f;
/**
* Default rate adjustment for text event feedback.
*/
private static final float DEFAULT_RATE = 1.0f;
/**
* Minimum delay between change and selection events.
*/
private static final long SELECTION_DELAY = 150;
/**
* Minimum delay between change events without an intervening selection.
*/
private static final long CHANGED_DELAY = 150;
/**
* Minimum delay between selection and movement at granularity events that could reflect
* the same cursor movement information.
*/
private static final long CURSOR_MOVEMENT_EVENTS_DELAY = 150;
private static final int VERBOSE_UTTERANCE_THRESHOLD_CHARACTERS = 50;
/**
* Event time of the most recently processed change event.
*/
private static long sChangedTimestamp = -1;
/**
* Package name of the most recently processed change event.
*/
private static CharSequence sChangedPackage = null;
/**
* The number of automatic selection events we're expecting to receive as a
* result of observed changed events. If this is > 0 and the selection delay
* has not elapsed, drop both selection and change events.
*/
private static int sAwaitingSelectionCount = 0;
private TextFormatters() {
// Not publicly instantiable.
}
/**
* Formatter that returns an utterance to announce text replacement.
*/
public static final class ChangedTextFormatter
implements EventSpeechRule.AccessibilityEventFormatter {
// These must be synchronized with @array/pref_keyboard_echo_values
// and @array/pref_keyboard_echo_entries in values/donottranslate.xml.
private static final int PREF_ECHO_ALWAYS = 0;
private static final int PREF_ECHO_SOFTKEYS = 1;
private static final int PREF_ECHO_NEVER = 2;
private static final int REJECTED = 0;
private static final int REMOVED = 1;
private static final int REPLACED = 2;
private static final int ADDED = 3;
@Override
public boolean format(AccessibilityEvent event, TalkBackService context,
Utterance utterance) {
final long timestamp = event.getEventTime();
// Drop change event if we're still waiting for a select event and
// the change occurred too soon after the previous change.
if (sAwaitingSelectionCount > 0) {
final boolean hasDelayElapsed =
((event.getEventTime() - sChangedTimestamp) >= CHANGED_DELAY);
final boolean hasPackageChanged =
!TextUtils.equals(event.getPackageName(), sChangedPackage);
// If the state is still consistent, update the count and drop
// the event except when running on locales that don't support
// text replacement due to character combination complexity.
if (!hasDelayElapsed && !hasPackageChanged
&& context.getResources().getBoolean(R.bool.supports_text_replacement)) {
sAwaitingSelectionCount++;
sChangedTimestamp = timestamp;
return false;
}
// The state became inconsistent, so reset the counter.
sAwaitingSelectionCount = 0;
}
final int changeType = formatInternal(event, context, utterance);
// Text changes should use a different voice from labels.
final Bundle params = new Bundle();
params.putFloat(SpeechController.SpeechParam.RATE, DEFAULT_RATE);
utterance.getMetadata().putBundle(Utterance.KEY_METADATA_SPEECH_PARAMS, params);
utterance.getMetadata().putInt(Utterance.KEY_UTTERANCE_GROUP,
SpeechController.UTTERANCE_GROUP_TEXT_SELECTION);
utterance.addSpokenFlag(
FeedbackItem.FLAG_CLEAR_QUEUED_UTTERANCES_WITH_SAME_UTTERANCE_GROUP);
utterance.addSpokenFlag(
FeedbackItem.FLAG_INTERRUPT_CURRENT_UTTERANCE_WITH_SAME_UTTERANCE_GROUP);
if (!isVerboseUtterance(utterance)) {
utterance.getMetadata().putInt(Utterance.KEY_METADATA_QUEUING,
SpeechController.QUEUE_MODE_UNINTERRUPTIBLE);
}
switch (changeType) {
case ADDED:
case REPLACED:
notifyMaxLengthReached(event, context, utterance);
notifyError(event, context, utterance);
params.putFloat(SpeechController.SpeechParam.PITCH, DEFAULT_ADD_PITCH);
// No auditory feedback for adding text.
break;
case REMOVED:
notifyError(event, context, utterance);
params.putFloat(SpeechController.SpeechParam.PITCH, DEFAULT_REMOVE_PITCH);
// No auditory feedback for removing text.
break;
case REJECTED:
return false;
}
sAwaitingSelectionCount = 1;
sChangedTimestamp = timestamp;
sChangedPackage = event.getPackageName();
return shouldEchoKeyboard(context, changeType);
}
private void notifyMaxLengthReached(AccessibilityEvent event,
TalkBackService context, Utterance utterance) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
// Check if entered text reached to maximum length
final AccessibilityNodeInfo source = event.getSource();
final CharSequence eventText = getEventText(event);
if (source != null
&& eventText != null
&& eventText.length() == source.getMaxTextLength()) {
utterance.addSpoken(context.getString(R.string.value_text_max_length));
}
}
}
private void notifyError(AccessibilityEvent event,
TalkBackService context, Utterance utterance) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
final AccessibilityNodeInfo source = event.getSource();
if (source != null && !TextUtils.isEmpty(source.getError())) {
utterance.addSpoken(
context.getString(R.string.template_text_error,
source.getError().toString()));
}
}
}
private boolean shouldEchoKeyboard(Context context, int changeType) {
// Always echo text removal events.
if (changeType == REMOVED) {
return true;
}
final SharedPreferences prefs = SharedPreferencesUtils.getSharedPreferences(context);
final Resources res = context.getResources();
final int keyboardPref = SharedPreferencesUtils.getIntFromStringPref(prefs, res,
R.string.pref_keyboard_echo_key, R.string.pref_keyboard_echo_default);
switch (keyboardPref) {
case PREF_ECHO_ALWAYS:
return true;
case PREF_ECHO_SOFTKEYS:
final Configuration config = res.getConfiguration();
return (config.keyboard == Configuration.KEYBOARD_NOKEYS) ||
(config.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_YES);
case PREF_ECHO_NEVER:
return false;
default:
LogUtils.log(this, Log.ERROR, "Invalid keyboard echo preference value: %d",
keyboardPref);
return false;
}
}
private int formatInternal(AccessibilityEvent event, TalkBackService context,
Utterance utterance) {
if (event.isPassword() && !shouldSpeakPasswords(context)) {
return formatPassword(event, context, utterance);
}
if (!passesSanityCheck(event)) {
LogUtils.log(this, Log.ERROR, "Inconsistent text change event detected");
return REJECTED;
}
final boolean isCutAction = EditTextActionHistory.getInstance()
.hasCutActionAtTime(event.getEventTime());
final boolean isPasteAction = EditTextActionHistory.getInstance()
.hasPasteActionAtTime(event.getEventTime());
// If no text was added but all the previous text was removed,
// we should notify the user that the text was cleared.
// Besides, if this event is triggered by a cut action, we should notify the user about
// the cut action.
final boolean wasCleared = event.getRemovedCount() > 1
&& event.getAddedCount() == 0
&& event.getBeforeText().length() == event.getRemovedCount();
if (wasCleared) {
if (isCutAction) {
utterance.addSpoken(context.getString(
R.string.template_text_cut,
SpeechCleanupUtils.cleanUp(context, event.getBeforeText())));
}
utterance.addSpoken(context.getString(R.string.value_text_cleared));
return REMOVED;
}
CharSequence removedText = getRemovedText(event);
CharSequence addedText = getAddedText(event);
// Never say "replaced Hello with Hello".
if (TextUtils.equals(addedText, removedText)) {
LogUtils.log(this, Log.DEBUG, "Drop event, nothing changed");
return REJECTED;
}
// Abort if either text is null (indicates an error).
if ((removedText == null) || (addedText == null)) {
LogUtils.log(this, Log.DEBUG, "Drop event, either added or removed was null");
return REJECTED;
}
final int removedLength = removedText.length();
final int addedLength = addedText.length();
// Translate partial replacement into addition / deletion.
if (removedLength > addedLength) {
if (TextUtils.regionMatches(removedText, 0, addedText, 0, addedLength)) {
removedText = removedText.subSequence(addedLength, removedLength);
addedText = "";
}
} else if (addedLength > removedLength) {
if (TextUtils.regionMatches(removedText, 0, addedText, 0, removedLength)) {
removedText = "";
addedText = addedText.subSequence(removedLength, addedLength);
}
}
// Apply any speech clean up rules. Usually this means changing "A"
// to "capital A" or "[" to "left bracket".
final CharSequence cleanRemovedText = SpeechCleanupUtils.cleanUp(context, removedText);
final CharSequence cleanAddedText = SpeechCleanupUtils.cleanUp(context, addedText);
if (!TextUtils.isEmpty(cleanAddedText)) {
// Text was added. This includes replacement.
//noinspection StatementWithEmptyBody
if (appendLastWordIfNeeded(event, utterance)) {
// Do nothing.
} else if (TextUtils.isEmpty(cleanRemovedText)
|| TextUtils.equals(cleanAddedText, cleanRemovedText)) {
if (isPasteAction) {
utterance.addSpoken(context.getString(
R.string.template_text_pasted,
cleanAddedText));
} else {
utterance.addSpoken(cleanAddedText);
}
} else if (!(context.getResources().getBoolean(R.bool.supports_text_replacement))) {
// The method of character substitution in some languages is
// identical to text replacement events. As such, we only
// speak the added text if the device locale matches one of
// these languages.
utterance.addSpoken(cleanAddedText);
} else {
// The addedText and the removedText are both not empty. Then we should
// announce it as a text replacement.
String replacedText = context.getString(R.string.template_text_replaced,
cleanAddedText, cleanRemovedText);
utterance.addSpoken(replacedText);
// If this text change event probably wasn't the result of a
// paste action, spell the added text aloud.
if (!isPasteAction) {
appendSpellingToUtterance(context, utterance, addedText);
}
return REPLACED;
}
return ADDED;
}
if (!TextUtils.isEmpty(cleanRemovedText)) {
int resId = isCutAction ? R.string.template_text_cut
: R.string.template_text_removed;
// Text was only removed.
utterance.addSpoken(context.getString(resId,
cleanRemovedText));
return REMOVED;
}
LogUtils.log(this, Log.DEBUG, "Drop event, cleaned up text was empty");
return REJECTED;
}
private boolean appendLastWordIfNeeded(AccessibilityEvent event, Utterance utterance) {
final CharSequence text = getEventText(event);
final CharSequence addedText = getAddedText(event);
final int fromIndex = event.getFromIndex();
if (fromIndex > text.length()) {
LogUtils.log(this, Log.WARN, "Received event with invalid fromIndex: %s", event);
return false;
}
// Check if any visible text was added.
int trimmedLength = TextUtils.getTrimmedLength(addedText);
if (trimmedLength > 0) {
return false;
}
final int breakIndex = getPrecedingWhitespace(text, fromIndex);
final CharSequence word = text.subSequence(breakIndex, fromIndex);
// Did the user just type a word?
if (TextUtils.getTrimmedLength(word) == 0) {
return false;
}
utterance.addSpoken(word);
return true;
}
private static void appendSpellingToUtterance(Context context, Utterance utterance,
CharSequence word) {
// Only spell words that consist of multiple characters.
if (word.length() <= 1) {
return;
}
for (int i = 0; i < word.length(); i++) {
final CharSequence character = Character.toString(word.charAt(i));
final CharSequence cleaned = SpeechCleanupUtils.cleanUp(context, character);
utterance.addSpoken(cleaned);
}
}
private static int getPrecedingWhitespace(CharSequence text, int fromIndex) {
for (int i = (fromIndex - 1); i > 0; i--) {
if (Character.isWhitespace(text.charAt(i))) {
return i;
}
}
return 0;
}
/**
* Checks whether the event's reported properties match its actual
* properties, e.g. does the added count minus the removed count reflect
* the actual change in length between the current and previous text
* contents.
*
* @param event The text changed event to validate.
* @return {@code true} if the event properties are valid.
*/
private boolean passesSanityCheck(AccessibilityEvent event) {
final CharSequence afterText = getEventText(event);
final CharSequence beforeText = event.getBeforeText();
// Special case for deleting all the text in an EditText with a
// hint, since the event text will contain the hint rather than an
// empty string.
if ((event.getAddedCount() == 0) && (event.getRemovedCount() == beforeText.length())) {
return true;
}
if (afterText == null || beforeText == null) {
return false;
}
final int diff = (event.getAddedCount() - event.getRemovedCount());
return ((beforeText.length() + diff) == afterText.length());
}
/**
* Attempts to extract the text that was added during an event.
*
* @param event The source event.
* @return The added text, or {@code null} on error.
*/
private CharSequence getAddedText(AccessibilityEvent event) {
final List<CharSequence> textList = event.getText();
//noinspection ConstantConditions
if (textList == null || textList.size() > 1) {
LogUtils.log(this, Log.WARN, "getAddedText: Text list was null or bad size");
return null;
}
// If the text was empty, the list will be empty. See the
// implementation for TextView.onPopulateAccessibilityEvent().
if (textList.size() == 0) {
return "";
}
final CharSequence text = textList.get(0);
if (text == null) {
LogUtils.log(this, Log.WARN, "getAddedText: First text entry was null");
return null;
}
final int addedBegIndex = event.getFromIndex();
final int addedEndIndex = addedBegIndex + event.getAddedCount();
if (areInvalidIndices(text, addedBegIndex, addedEndIndex)) {
LogUtils.log(this, Log.WARN, "getAddedText: Invalid indices (%d,%d) for \"%s\"",
addedBegIndex, addedEndIndex, text);
return "";
}
return text.subSequence(addedBegIndex, addedEndIndex);
}
/**
* Attempts to extract the text that was removed during an event.
*
* @param event The source event.
* @return The removed text, or {@code null} on error.
*/
private CharSequence getRemovedText(AccessibilityEvent event) {
final CharSequence beforeText = event.getBeforeText();
if (beforeText == null) {
return null;
}
final int beforeBegIndex = event.getFromIndex();
final int beforeEndIndex = beforeBegIndex + event.getRemovedCount();
if (areInvalidIndices(beforeText, beforeBegIndex, beforeEndIndex)) {
return "";
}
return beforeText.subSequence(beforeBegIndex, beforeEndIndex);
}
/**
* Formats "secure" password feedback from event text.
*
* @param event The source event.
* @param context The application context.
* @param utterance The utterance to populate.
* @return {@code false} on error.
*/
private int formatPassword(AccessibilityEvent event, Context context, Utterance utterance) {
int removed = event.getRemovedCount();
int added = event.getAddedCount();
// there is bug that sometimes web edit fields send negative indexes. we need to check
// if index is negative
if ((added <= 0) && (removed <= 0)) {
return REJECTED;
} else if ((added == 1) && (removed <= 0)) {
utterance.addSpoken(context.getString(R.string.symbol_bullet));
return ADDED;
} else if ((added <= 0) && (removed == 1)) {
utterance.addSpoken(context.getString(
R.string.template_text_removed, context.getString(R.string.symbol_bullet)));
return REMOVED;
} else {
utterance.addSpoken(context.getString(R.string.template_replaced_characters,
removed, added));
return REPLACED;
}
}
}
/**
* Formatter that returns an utterance to announce text selection.
*/
public static final class SelectedTextFormatter
implements EventSpeechRule.AccessibilityEventFormatter {
@IntDef({UNPARSED_ACTION, FOCUS_EDIT_TEXT, MOVE_CURSOR_TO_BEGINNING, MOVE_CURSOR_TO_END,
MOVE_CURSOR_WITHOUT_SELECTION_MODE, MOVE_CURSOR_WITHIN_SELECTION_MODE, CUT, PASTE,
SELECT_ALL, MOVE_CURSOR_AND_SELECTION_CLEARED, TEXT_TRAVERSAL})
@Retention(RetentionPolicy.SOURCE)
public @interface TextAction {
}
private static final int UNPARSED_ACTION = -1;
private static final int FOCUS_EDIT_TEXT = 0;
private static final int MOVE_CURSOR_TO_BEGINNING = 1;
private static final int MOVE_CURSOR_TO_END = 2;
private static final int MOVE_CURSOR_WITHOUT_SELECTION_MODE = 3;
private static final int MOVE_CURSOR_WITHIN_SELECTION_MODE = 4;
private static final int CUT = 5;
private static final int PASTE = 6;
private static final int SELECT_ALL = 7;
private static final int MOVE_CURSOR_AND_SELECTION_CLEARED = 8;
private static final int TEXT_TRAVERSAL = 9;
private static final int NO_INDEX = -1;
private AccessibilityEvent mLastProcessedEvent;
private int mLastFromIndex = NO_INDEX;
private int mLastToIndex = NO_INDEX;
private AccessibilityNodeInfo mLastNode = null;
@Override
public boolean format(AccessibilityEvent event, TalkBackService context,
Utterance utterance) {
boolean result = formatInternal(event, context, utterance);
utterance.getMetadata().putInt(Utterance.KEY_UTTERANCE_GROUP,
SpeechController.UTTERANCE_GROUP_TEXT_SELECTION);
utterance.addSpokenFlag(
FeedbackItem.FLAG_CLEAR_QUEUED_UTTERANCES_WITH_SAME_UTTERANCE_GROUP);
utterance.addSpokenFlag(
FeedbackItem.FLAG_INTERRUPT_CURRENT_UTTERANCE_WITH_SAME_UTTERANCE_GROUP);
if (!isVerboseUtterance(utterance)) {
utterance.getMetadata().putInt(Utterance.KEY_METADATA_QUEUING,
SpeechController.QUEUE_MODE_UNINTERRUPTIBLE);
}
return result;
}
private boolean formatInternal(AccessibilityEvent event, TalkBackService context,
Utterance utterance) {
if (shouldSkipCursorMovementEvent(event) || shouldDropEvent(event)) {
return false;
}
final boolean isGranularTraversal = (event.getEventType() ==
AccessibilityEventCompat.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY);
final CharSequence text;
if (isGranularTraversal) {
// Use the description (if present) or aggregate event text.
text = AccessibilityEventUtils.getEventTextOrDescription(event);
} else {
// Only use the first item from getText().
text = getEventText(event);
}
// Don't provide selection feedback when there's no text. We have to
// check the item count separately to avoid speaking hint text,
// which always has an item count of zero even though the event text
// is not empty. Note that, on <= M, password text is empty but the count is nonzero.
final int count = event.getItemCount();
if ((TextUtils.isEmpty(text) && !event.isPassword()) || (count == 0)) {
return false;
}
TextCursorController textCursorController = context.getTextCursorController();
int toIndex = event.getToIndex();
int fromIndex = event.getFromIndex();
int previousCursorPos = textCursorController.getPreviousCursorPosition();
int currentCursorPos = textCursorController.getCurrentCursorPosition();
int textLength = text.length();
boolean isSelectionModeActive = context.getCursorController().isSelectionModeActive();
final @TextAction int action = parseAction(event.getSource(), event.getEventType(),
event.getEventTime(),
fromIndex, toIndex,
mLastFromIndex, mLastToIndex,
previousCursorPos, currentCursorPos,
textLength, isSelectionModeActive);
switch (action) {
case FOCUS_EDIT_TEXT:
mLastFromIndex = NO_INDEX;
mLastToIndex = NO_INDEX;
if (mLastNode != null) {
mLastNode.recycle();
}
mLastNode = event.getSource();
break;
case MOVE_CURSOR_TO_BEGINNING:
case MOVE_CURSOR_TO_END:
// The hints of these two actions are announced in menurules.RuleEditText.
break;
case MOVE_CURSOR_WITHOUT_SELECTION_MODE:
processEvent(event, utterance, SpeechCleanupUtils.cleanUp(context,
getSubsequence(context, event, text,
Math.min(mLastToIndex, toIndex),
Math.max(mLastToIndex, toIndex))));
if (toIndex == 0) {
utterance.addSpoken(context.getString(
R.string.notification_type_beginning_of_field));
} else if (toIndex == event.getItemCount()) {
utterance.addSpoken(context.getString(
R.string.notification_type_end_of_field));
}
break;
case MOVE_CURSOR_WITHIN_SELECTION_MODE:
processEvent(event, utterance, null);
CharSequence unselectedText = getUnselectedText(context, event, text, fromIndex,
toIndex, mLastToIndex);
if (!TextUtils.isEmpty(unselectedText)) {
utterance.addSpoken(context.getString(
R.string.template_text_unselected,
SpeechCleanupUtils.cleanUp(context, unselectedText)));
}
CharSequence selectedText = getSelectedText(context, event, text, fromIndex,
toIndex, mLastToIndex);
if (!TextUtils.isEmpty(selectedText)) {
utterance.addSpoken(context.getString(
R.string.template_text_selected,
SpeechCleanupUtils.cleanUp(context, selectedText)));
}
break;
case MOVE_CURSOR_AND_SELECTION_CLEARED:
utterance.addSpoken(context.getString(
R.string.notification_type_selection_cleared));
if (toIndex == 0) {
utterance.addSpoken(context.getString(
R.string.notification_type_beginning_of_field));
} else if (toIndex == event.getItemCount()) {
utterance.addSpoken(context.getString(
R.string.notification_type_end_of_field));
}
break;
case TEXT_TRAVERSAL:
if (event.getMovementGranularity() == AccessibilityNodeInfoCompat
.MOVEMENT_GRANULARITY_CHARACTER) {
utterance.addSpoken(String.valueOf(text.charAt(
Math.min(fromIndex, toIndex))));
} else {
utterance.addSpoken(text.subSequence(
Math.min(fromIndex, toIndex),
Math.max(fromIndex, toIndex)
));
}
break;
case SELECT_ALL:
// Select all result is announced in menurules.RuleEditText
// In some cases if all the text has already been selected, the "Select All"
// action will not trigger SelectionChangedEvent. So we should not handle the
// announcement here.
case CUT:
case PASTE:
// Cut and Paste results are announced in ChangedTextFormatter
break;
default:
// The default action type is UNPARSED_ACTION. This kind of events cannot be
// handled, so we will stop the its propagation and return false.
return false;
}
if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) {
mLastFromIndex = fromIndex;
mLastToIndex = toIndex;
}
return true;
}
private CharSequence getUnselectedText(TalkBackService context, AccessibilityEvent event,
CharSequence text, int fromIndex, int toIndex,
int lastToIndex) {
if (fromIndex < lastToIndex && toIndex < lastToIndex) {
return getSubsequence(context, event, text, Math.max(fromIndex, toIndex),
lastToIndex);
} else if (fromIndex > lastToIndex && toIndex > lastToIndex) {
return getSubsequence(context, event, text, lastToIndex,
Math.min(fromIndex, toIndex));
} else {
return null;
}
}
private CharSequence getSelectedText(TalkBackService context, AccessibilityEvent event,
CharSequence text, int fromIndex, int toIndex,
int lastToIndex) {
if (fromIndex < toIndex && lastToIndex < toIndex) {
return getSubsequence(context, event, text, Math.max(fromIndex, lastToIndex),
toIndex);
} else if (fromIndex > toIndex && lastToIndex > toIndex) {
return getSubsequence(context, event, text, toIndex,
Math.min(fromIndex, lastToIndex));
} else {
return null;
}
}
private @TextAction int parseAction(AccessibilityNodeInfo node, int eventType,
long eventTime,
int fromIndex, int toIndex,
int lastFromIndex, int lastToIndex,
int previousCursorPos, int currentCursorPos,
int textLength, boolean isSelectionModeActive) {
if (eventType == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) {
if (!node.equals(mLastNode)) {
return FOCUS_EDIT_TEXT;
} else if (EditTextActionHistory.getInstance().hasCutActionAtTime(eventTime)
&& fromIndex == toIndex) {
return CUT;
} else if (EditTextActionHistory.getInstance().hasPasteActionAtTime(eventTime)) {
return PASTE;
} else if (fromIndex == 0 && toIndex == 0
&& previousCursorPos == 0 && currentCursorPos == 0) {
return MOVE_CURSOR_TO_BEGINNING;
} else if (fromIndex == textLength && toIndex == textLength
&& previousCursorPos == textLength && currentCursorPos == textLength) {
return MOVE_CURSOR_TO_END;
} else if (fromIndex == 0
&& toIndex == textLength
&& EditTextActionHistory.getInstance()
.hasSelectAllActionAtTime(eventTime)) {
return SELECT_ALL;
} else if (fromIndex == toIndex && lastFromIndex == lastToIndex
&& toIndex == currentCursorPos && lastToIndex == previousCursorPos) {
return MOVE_CURSOR_WITHOUT_SELECTION_MODE;
} else if (isSelectionModeActive
&& lastFromIndex == fromIndex && lastToIndex == previousCursorPos
&& toIndex == currentCursorPos) {
return MOVE_CURSOR_WITHIN_SELECTION_MODE;
} else if (lastFromIndex != lastToIndex && fromIndex == toIndex) {
return MOVE_CURSOR_AND_SELECTION_CLEARED;
}
} else if (eventType == AccessibilityEvent
.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY) {
if (fromIndex >= 0 && fromIndex <= textLength
&& toIndex >= 0 && toIndex <= textLength) {
return TEXT_TRAVERSAL;
}
}
return UNPARSED_ACTION;
}
private boolean shouldSkipCursorMovementEvent(AccessibilityEvent event) {
if (mLastProcessedEvent == null) {
return false;
}
if (event.getEventTime() - mLastProcessedEvent.getEventTime() >
CURSOR_MOVEMENT_EVENTS_DELAY) {
mLastProcessedEvent.recycle();
mLastProcessedEvent = null;
return false;
}
//noinspection SimplifiableIfStatement
if (event.getEventType() == mLastProcessedEvent.getEventType()) {
// if events have the same type they are results of different actions
return false;
}
if (mLastProcessedEvent.getEventType()
== AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED
&& event.getEventType()
== AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY) {
return true;
}
return false;
}
private void processEvent(AccessibilityEvent event, Utterance utterance,
CharSequence text) {
if (text != null) {
utterance.addSpoken(text);
}
if (mLastProcessedEvent != null) {
mLastProcessedEvent.recycle();
}
mLastProcessedEvent = AccessibilityEvent.obtain(event);
}
/**
* Returns {@code true} if the specified event is a selection event and
* should be dropped without providing feedback. Always returns
* {@code false} for non-selection events.
*/
private boolean shouldDropEvent(AccessibilityEvent event) {
// Only operate on selection events. Never drop granular movement
// events or other event types.
final int eventType = event.getEventType();
if (eventType != AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) {
return false;
}
// Drop selected events until we've matched the number of changed
// events. This prevents TalkBack from speaking automatic cursor
// movement events that result from typing.
if (sAwaitingSelectionCount > 0) {
final boolean hasDelayElapsed =
((event.getEventTime() - sChangedTimestamp) >= SELECTION_DELAY);
final boolean hasPackageChanged =
!TextUtils.equals(event.getPackageName(), sChangedPackage);
// If the state is still consistent, update the count and drop
// the event.
if (!hasDelayElapsed && !hasPackageChanged) {
sAwaitingSelectionCount--;
mLastFromIndex = event.getFromIndex();
mLastToIndex = event.getToIndex();
if (mLastNode != null) {
mLastNode.recycle();
}
mLastNode = event.getSource();
return true;
}
// The state became inconsistent, so reset the counter.
sAwaitingSelectionCount = 0;
}
// Drop selection events from views that don't have input focus.
final AccessibilityRecordCompat record = AccessibilityEventCompat.asRecord(event);
final AccessibilityNodeInfoCompat source = record.getSource();
if ((source != null) && !source.isFocused()) {
LogUtils.log(this, Log.VERBOSE, "Dropped selection event from non-focused field");
return true;
}
return false;
}
/**
* Gets the subsequence {@code [from, to)} of the given text. If the text is a password
* and the password cannot be read aloud, then returns a suitable substitute description,
* such as "Character 3" or "Characters 3 to 4".
* @param context the current TalkBack service
* @param event the selection change/granularity event for which we are providing feedback
* @param text the text from which we need to extract a subsequence (or for which the
* password substitution needs to be provided)
* @param from the beginning index (inclusive)
* @param to the ending index (exclusive)
* @return the requested subsequence or an alternate description for passwords
*/
private CharSequence getSubsequence(TalkBackService context,
AccessibilityEvent event,
CharSequence text,
int from,
int to) {
if (event.isPassword() && !shouldSpeakPasswords(context)) {
if (to - from == 1) {
return context.getString(R.string.template_password_traversed, from + 1);
} else {
return context.getString(R.string.template_password_selected, from + 1, to + 1);
}
} else {
return text.subSequence(from, to);
}
}
}
/**
* Returns whether a set of indices are valid for a given
* {@link CharSequence}.
*
* @param text The sequence to examine.
* @param begin The beginning index.
* @param end The end index.
* @return {@code true} if the indices are valid.
*/
private static boolean areInvalidIndices(CharSequence text, int begin, int end) {
return (begin < 0) || (end > text.length()) || (begin >= end);
}
/**
* Returns the text for an event sent from a {@link android.widget.TextView}
* widget.
*
* @param event The source event.
* @return The widget text, or {@code null}.
*/
private static CharSequence getEventText(AccessibilityEvent event) {
final List<CharSequence> eventText = event.getText();
if (eventText.isEmpty()) {
return "";
}
return eventText.get(0);
}
private static boolean shouldSpeakPasswords(TalkBackService service) {
if (service == null) {
return false;
}
return SettingsCompatUtils.SecureCompatUtils.shouldSpeakPasswords(service);
}
private static boolean isVerboseUtterance(Utterance utterance) {
List<CharSequence> texts = utterance.getSpoken();
int count = 0;
int textCount = texts.size();
for (int i = 0; i < textCount; i++) {
CharSequence text = texts.get(i);
if (!TextUtils.isEmpty(text)) {
count += text.length();
}
}
return count > VERBOSE_UTTERANCE_THRESHOLD_CHARACTERS;
}
}