Java tutorial
/* * 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.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.preference.PreferenceManager; 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.screenspeak.FeedbackItem; import com.android.screenspeak.PasteHistory; import com.android.screenspeak.R; import com.android.screenspeak.SpeechCleanupUtils; import com.android.screenspeak.SpeechController; import com.google.android.marvin.screenspeak.ScreenSpeakService; import com.android.screenspeak.Utterance; import com.android.screenspeak.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.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, ScreenSpeakService 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, ScreenSpeakService 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, ScreenSpeakService 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 = PreferenceManager.getDefaultSharedPreferences(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, ScreenSpeakService 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; } // If multi-character text was cleared, stop now. final boolean wasCleared = event.getRemovedCount() > 1 && event.getAddedCount() == 0 && event.getBeforeText().length() == event.getRemovedCount(); if (wasCleared) { 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)) { 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 { 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 (!PasteHistory.getInstance().hasActionAtTime(event.getEventTime())) { appendSpellingToUtterance(context, utterance, addedText); } return REPLACED; } return ADDED; } if (!TextUtils.isEmpty(cleanRemovedText)) { // Text was only removed. utterance.addSpoken(context.getString(R.string.template_text_removed, 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 { private AccessibilityEvent mLastProcessedEvent; @Override public boolean format(AccessibilityEvent event, ScreenSpeakService 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, ScreenSpeakService context, Utterance utterance) { if (isProcessedEvent(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); } final int count = event.getItemCount(); if (event.isPassword() && !shouldSpeakPasswords(context)) { return formatPassword(event, context, utterance); } // 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. if (TextUtils.isEmpty(text) || (count == 0)) { return false; } int endIndex = event.getToIndex(); int begIndex = event.getFromIndex(); // if it is TYPE_VIEW_TEXT_SELECTION_CHANGED event that represent cursor movement // without selection get begining and end indexes from TextCursorController if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED && endIndex == begIndex) { ScreenSpeakService service = ScreenSpeakService.getInstance(); if (service == null) { return false; } TextCursorController controller = service.getTextCursorController(); begIndex = controller.getPreviousCursorPosition(); endIndex = controller.getCurrentCursorPosition(); } if (begIndex > endIndex) { int temp = begIndex; begIndex = endIndex; endIndex = temp; } if (areInvalidIndices(text, begIndex, endIndex)) { return false; } processEvent(event, utterance, SpeechCleanupUtils.cleanUp(context, text.subSequence(begIndex, endIndex))); return true; } private boolean isProcessedEvent(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; } return (event.getToIndex() == mLastProcessedEvent.getToIndex()) || (event.getFromIndex() == mLastProcessedEvent.getFromIndex()); } private void processEvent(AccessibilityEvent event, Utterance utterance, CharSequence text) { utterance.addSpoken(text); if (mLastProcessedEvent != null) { mLastProcessedEvent.recycle(); } mLastProcessedEvent = AccessibilityEvent.obtain(event); } private boolean isCharacterTraversal(AccessibilityEvent event) { if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY && event .getMovementGranularity() == AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_CHARACTER) { return true; } ScreenSpeakService service = ScreenSpeakService.getInstance(); if (service == null) { return false; } TextCursorController textCursorController = service.getTextCursorController(); int currentIndex = textCursorController.getCurrentCursorPosition(); int previousIndex = textCursorController.getPreviousCursorPosition(); //noinspection SimplifiableIfStatement,RedundantIfStatement if (currentIndex != TextCursorController.NO_POSITION && previousIndex != TextCursorController.NO_POSITION && currentIndex == event.getToIndex() && Math.abs(previousIndex - currentIndex) == 1) { return true; } return false; } /** * 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 ScreenSpeak 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--; 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; } /** * 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 boolean formatPassword(AccessibilityEvent event, Context context, Utterance utterance) { final AccessibilityRecordCompat record = AccessibilityEventCompat.asRecord(event); final int fromIndex = event.getFromIndex(); final int toIndex = record.getToIndex(); if (toIndex <= fromIndex) { return false; } final CharSequence formattedText = context.getString(R.string.template_password_selected, fromIndex, toIndex); utterance.addSpoken(formattedText); return true; } } /** * 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(ScreenSpeakService 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; } }