diff --git a/differences.md b/differences.md index 2565d0a87..9e060e3f3 100644 --- a/differences.md +++ b/differences.md @@ -209,6 +209,39 @@ With **Controls** > **Touch and activation** > **Stop speech when touching the s When you turn speech off with its gesture or keyboard shortcut, TalkBack also dropped the sounds and vibrations that go with each item, so moving focus or activating an item gave no feedback at all. Backtalk still plays them, so you can tell that focus moved while speech is off. +### Speak numbers as digits + +In **Verbosity**, **Speak numbers as digits** reads consecutive digits separately, +including leading zeros. For example, `000123` is spoken as `0 0 0 1 2 3`. +It is off by default. Android's native digits pronunciation hints were investigated +first: Google's engine honoured them in a standalone test, but Vocalizer Tessa did +not reliably produce the requested reading. Explicit spacing was chosen after +Oppo tests with Google TTS and Tessa produced the intended individual-digit output. +Neither approach guarantees identical behaviour across engines or voices. + +It changes only the text sent for speech; the app's text and +accessibility navigation stay unchanged. Existing spaces and punctuation characters are kept. +Commas, periods, plus and minus signs, currency symbols and letters immediately +next to a digit are separated too: `$1,500.00` is sent for speech as +`$ 1 , 5 0 0 . 0 0`, and `R7` as `R 7`. This applies to letter/digit boundaries +in other text too, such as `A12B` becoming `A 1 2 B`. Other punctuation is left +unchanged. Punctuation is still spoken according to the existing punctuation +setting and speech engine. + +On an Oppo CPH2197 running Android 13 with Vocalizer Tessa, turning this option +off kept the usual number pronunciation, and turning it on read consecutive +digits individually. +Delimiters were not reliably announced with Vocalizer's own **Process punctuation** +setting off. Turning on **Process punctuation** and selecting **Most** allowed +commas, periods and other punctuation to be announced. Tessa called the minus +character "dash". These are findings from that synthesizer and device; other +speech engines may pronounce the digits and punctuation differently, including how they say zero. + +When new speech is generated for digit-spaced text, app-provided spoken wording +that crosses a speech chunk or resume boundary falls back to the visible text for +that portion. This avoids repeating the complete replacement. Complete spoken +replacements and other text formatting are preserved. + ## Notifications ### Samsung watch notification content diff --git a/lint.xml b/lint.xml index 6efa1f0c4..2e1eabb00 100644 --- a/lint.xml +++ b/lint.xml @@ -6,6 +6,8 @@ Keep MissingTranslation enabled globally and suppress it only for these exact resources. --> + + diff --git a/talkback/src/main/java/com/google/android/accessibility/talkback/TalkBackService.java b/talkback/src/main/java/com/google/android/accessibility/talkback/TalkBackService.java index 286780a28..741f0afe6 100644 --- a/talkback/src/main/java/com/google/android/accessibility/talkback/TalkBackService.java +++ b/talkback/src/main/java/com/google/android/accessibility/talkback/TalkBackService.java @@ -307,6 +307,7 @@ import com.google.android.accessibility.utils.output.FailoverTextToSpeech; import com.google.android.accessibility.utils.output.FeedbackController; import com.google.android.accessibility.utils.output.FeedbackProcessingUtils; +import com.google.android.accessibility.utils.output.NumbersAsDigits; import com.google.android.accessibility.utils.output.ScrollActionRecord; import com.google.android.accessibility.utils.output.SelectionStateReader; import com.google.android.accessibility.utils.output.SpeechController; @@ -3585,6 +3586,10 @@ protected void reloadPreferences(@Nullable String key) { res, R.string.pref_punctuation_verbosity, R.string.pref_punctuation_verbosity_default))); + NumbersAsDigits.setEnabled( + getBooleanPref( + R.string.pref_speak_numbers_as_digits_key, + R.bool.pref_speak_numbers_as_digits_default)); EmojiSpeech.setMode( this, SharedPreferencesUtils.getStringPref( diff --git a/talkback/src/main/res/values/donottranslate.xml b/talkback/src/main/res/values/donottranslate.xml index dec2657f4..11cb83015 100644 --- a/talkback/src/main/res/values/donottranslate.xml +++ b/talkback/src/main/res/values/donottranslate.xml @@ -2148,4 +2148,6 @@ pref_restore_focus_on_back pref_scroll_to_restore_previous_focus + pref_speak_numbers_as_digits + false diff --git a/talkback/src/main/res/values/strings.xml b/talkback/src/main/res/values/strings.xml index fcde547e7..f57de8444 100644 --- a/talkback/src/main/res/values/strings.xml +++ b/talkback/src/main/res/values/strings.xml @@ -4494,4 +4494,8 @@ Do not use any markdown in your response." Restore focus when going back Return to the last focused item when going back to a screen. + + Speak numbers as digits + + Read digits individually, including leading zeros, and separate adjacent letters, currency symbols and numeric punctuation. diff --git a/talkback/src/main/res/xml/verbosity_preferences.xml b/talkback/src/main/res/xml/verbosity_preferences.xml index ed8daefe5..6fecb5240 100644 --- a/talkback/src/main/res/xml/verbosity_preferences.xml +++ b/talkback/src/main/res/xml/verbosity_preferences.xml @@ -182,6 +182,13 @@ android:key="@string/pref_speak_element_ids_key" android:summary="@string/summary_pref_speak_element_ids" android:title="@string/title_pref_speak_element_ids" /> + + + + var found = 0 + while (xml.next() != XmlPullParser.END_DOCUMENT) { + if (xml.eventType != XmlPullParser.START_TAG) continue + val ns = "http://schemas.android.com/apk/res/android" + if (xml.getAttributeResourceValue(ns, "key", 0) != R.string.pref_speak_numbers_as_digits_key) continue + found++ + assertEquals(R.bool.pref_speak_numbers_as_digits_default, + xml.getAttributeResourceValue(ns, "defaultValue", 0)) + assertTrue(xml.name.endsWith("AccessibilitySuiteSwitchPreference")) + } + assertEquals(1, found) + } + } + + private fun read(prefs: SharedPreferences, context: Context) = + SharedPreferencesUtils.getBooleanPref(prefs, context.resources, + R.string.pref_speak_numbers_as_digits_key, R.bool.pref_speak_numbers_as_digits_default) +} diff --git a/talkback/src/wear/res/xml-watch/verbosity_preferences.xml b/talkback/src/wear/res/xml-watch/verbosity_preferences.xml index 3e64d7f7c..24130c550 100644 --- a/talkback/src/wear/res/xml-watch/verbosity_preferences.xml +++ b/talkback/src/wear/res/xml-watch/verbosity_preferences.xml @@ -174,6 +174,14 @@ android:title="@string/title_pref_speak_element_ids" app:splitSummary="true" /> + + + starts = - utteranceId == null || !speaksInPhrases() + utteranceId == null + || (!speaksInPhrases() && text.length() < TextToSpeech.getMaxSpeechInputLength()) ? Collections.singletonList(0) : SpeechChunker.chunkStarts(text, locale == null ? Locale.getDefault() : locale); if (starts.size() <= 1) { @@ -1509,7 +1522,11 @@ private int speakInChunks( String chunkId = utteranceId + CHUNK_ID_SEPARATOR + i; speechChunks.put(chunkId, new SpeechChunk(utteranceId, start, i == 0, last)); int result = - ttsSpeak(text.subSequence(start, end), i == 0 ? queueMode : QUEUE_ADD, bundle, chunkId); + ttsSpeak( + NumbersAsDigits.subSequenceForSpeech(text, start, end), + i == 0 ? queueMode : QUEUE_ADD, + bundle, + chunkId); if (result != TextToSpeech.SUCCESS) { speechChunks.remove(chunkId); if (i == 0) { diff --git a/utils/src/main/java/com/google/android/accessibility/utils/output/FeedbackFragment.java b/utils/src/main/java/com/google/android/accessibility/utils/output/FeedbackFragment.java index c5f41aadc..c5d734e91 100644 --- a/utils/src/main/java/com/google/android/accessibility/utils/output/FeedbackFragment.java +++ b/utils/src/main/java/com/google/android/accessibility/utils/output/FeedbackFragment.java @@ -234,9 +234,11 @@ void recordFragmentStartIndex(int utteranceStartIndex) { } /** Update {@link #mText} and {@link #startIndexInFeedbackItem} by {@link #fragmentStartIndex}. */ - void updateContentByFragmentStartIndex() { + void updateContentByFragmentStartIndex(boolean preparedSpeech) { if (fragmentStartIndex < mText.length()) { - CharSequence remainingSequence = mText.subSequence(fragmentStartIndex, mText.length()); + CharSequence remainingSequence = + NumbersAsDigits.subSequenceForSpeech( + mText, fragmentStartIndex, mText.length(), preparedSpeech); mText = remainingSequence; startIndexInFeedbackItem += fragmentStartIndex; fragmentStartIndex = 0; diff --git a/utils/src/main/java/com/google/android/accessibility/utils/output/FeedbackFragmentsIterator.java b/utils/src/main/java/com/google/android/accessibility/utils/output/FeedbackFragmentsIterator.java index d38e8a968..86cb77b91 100644 --- a/utils/src/main/java/com/google/android/accessibility/utils/output/FeedbackFragmentsIterator.java +++ b/utils/src/main/java/com/google/android/accessibility/utils/output/FeedbackFragmentsIterator.java @@ -41,6 +41,25 @@ class FeedbackFragmentsIterator { // was paused and resumed, rather than a new one. private boolean nextIsContinuation = false; + @Nullable private volatile NumbersAsDigits.Result numberSpeech; + private boolean preparedContinuation; + + /** Prepares only the outgoing copy; the fragment retains original text for resume and history. */ + @Nullable + CharSequence prepareSpeechText(@Nullable CharSequence text) { + NumbersAsDigits.Result speech = NumbersAsDigits.process(text); + numberSpeech = speech; + if (speech != null) { + return speech.text; + } + return preparedContinuation && text != null ? NumbersAsDigits.protect(text) : text; + } + + int sourceOffset(int speechOffset) { + NumbersAsDigits.Result speech = numberSpeech; + return speech == null ? speechOffset : speech.sourceOffset(speechOffset); + } + public FeedbackFragmentsIterator( @NonNull Iterator currentFragmentIterator, String feedBackItemUtteranceId) { this.currentFragmentIterator = currentFragmentIterator; @@ -56,8 +75,10 @@ FeedbackFragment next() { nextIsContinuation = feedbackFragment != null; if (feedbackFragment != null) { // Has pending Fragment. - feedbackFragment.updateContentByFragmentStartIndex(); + preparedContinuation |= numberSpeech != null; + feedbackFragment.updateContentByFragmentStartIndex(preparedContinuation); } else if (currentFragmentIterator.hasNext()) { + preparedContinuation = false; feedbackFragment = currentFragmentIterator.next(); currentFeedbackFragment.set(feedbackFragment); } @@ -146,6 +167,8 @@ public FeedbackFragmentsIterator deepCopy() { FeedbackFragmentsIterator clone = new FeedbackFragmentsIterator(list.iterator(), feedBackItemUtteranceId); clone.currentFeedbackFragment.set(currentFeedbackFragment.get()); + clone.numberSpeech = numberSpeech; + clone.preparedContinuation = preparedContinuation; currentFragmentIterator = ((ArrayList) list.clone()).iterator(); diff --git a/utils/src/main/java/com/google/android/accessibility/utils/output/NumbersAsDigits.kt b/utils/src/main/java/com/google/android/accessibility/utils/output/NumbersAsDigits.kt new file mode 100644 index 000000000..131cbdc9d --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/output/NumbersAsDigits.kt @@ -0,0 +1,142 @@ +/* + * Copyright 2026 Backtalk contributors + * + * 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.google.android.accessibility.utils.output + +import android.text.SpannableString +import android.text.Spanned +import android.text.style.TtsSpan + +/** Separates digits and adjacent delimiters in a speech-only copy, retaining source positions. */ +object NumbersAsDigits { + @Volatile private var enabled = false + + @JvmStatic + fun setEnabled(value: Boolean) { + enabled = value + } + + @JvmStatic + fun process(source: CharSequence?): Result? = process(source, enabled) + + /** Null means unchanged; disabled speech keeps its existing path and source object. */ + @JvmStatic + fun process(source: CharSequence?, enabled: Boolean): Result? { + if (!enabled || source == null || source.length < 2) return null + val length = source.length + val insertBefore = BooleanArray(length + 1) + var spaces = 0 + var previous = -1 + var index = 0 + while (index < length) { + val point = Character.codePointAt(source, index) + if ((Character.isDigit(previous) && isNumericNeighbour(point)) || + (Character.isDigit(point) && isNumericNeighbour(previous))) { + insertBefore[index] = true + spaces++ + } + previous = point + index += Character.charCount(point) + } + if (spaces == 0) return null + + val output = StringBuilder(length + spaces) + val sourceToSpeech = IntArray(length + 1) + val speechToSource = IntArray(length + spaces + 1) + for (i in 0 until length) { + if (insertBefore[i]) { + output.append(' ') + speechToSource[output.length] = i + } + sourceToSpeech[i] = output.length + output.append(source[i]) + speechToSource[output.length] = i + 1 + } + sourceToSpeech[length] = output.length + var speech: CharSequence = output.toString() + if (source is Spanned) { + val copy = SpannableString(speech) + for (span in source.getSpans(0, length, Any::class.java)) { + val start = source.getSpanStart(span) + val end = source.getSpanEnd(span) + val mappedStart = sourceToSpeech[start] + val mappedEnd = sourceToSpeech[end] - if (insertBefore[end]) 1 else 0 + // Numeric interpretations would undo spacing. Explicit wording, punctuation, + // language, formatting and digits instructions remain on the private speech copy. + if (span is TtsSpan && conflictsWithSpacing(span) && mappedEnd - mappedStart > end - start) { + continue + } + copy.setSpan(span, mappedStart, maxOf(mappedStart, mappedEnd), source.getSpanFlags(span)) + } + speech = copy + } + return Result(protect(speech), speechToSource) + } + + /** Tags a private copy so protection follows queued speech and resumed suffixes. */ + @JvmStatic + fun protect(text: CharSequence): CharSequence { + if (isPreparedText(text)) return text + return SpannableString(text).apply { + setSpan(PreparedSpeechSpan(), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + + @JvmStatic + fun isPreparedText(text: CharSequence?): Boolean = + text is Spanned && text.getSpans(0, text.length, PreparedSpeechSpan::class.java).isNotEmpty() + + @JvmStatic + fun subSequenceForSpeech(text: CharSequence, start: Int, end: Int): CharSequence = + subSequenceForSpeech(text, start, end, isPreparedText(text)) + + /** Partial replacement arguments cannot be split by offsets: use their visible text instead. */ + @JvmStatic + fun subSequenceForSpeech( + text: CharSequence, start: Int, end: Int, preparedSpeech: Boolean, + ): CharSequence { + val slice = text.subSequence(start, end) + if (!preparedSpeech || text !is Spanned) return slice + var copy: SpannableString? = null + for (span in text.getSpans(start, end, TtsSpan::class.java)) { + if (!span.args.isEmpty && (text.getSpanStart(span) < start || text.getSpanEnd(span) > end)) { + val speech = copy ?: SpannableString(slice).also { copy = it } + speech.removeSpan(span) + } + } + return copy ?: slice + } + + private class PreparedSpeechSpan + + /** Character boundaries only: no currency, decimal, sign or identifier parsing. */ + private fun isNumericNeighbour(point: Int): Boolean = + Character.isLetterOrDigit(point) || Character.getType(point) == Character.CURRENCY_SYMBOL.toInt() || + point == ','.code || point == '.'.code || point == '+'.code || point == '-'.code || point == 0x2212 + + private fun conflictsWithSpacing(span: TtsSpan): Boolean = when (span.type) { + TtsSpan.TYPE_CARDINAL, TtsSpan.TYPE_ORDINAL, TtsSpan.TYPE_DECIMAL, TtsSpan.TYPE_FRACTION, + TtsSpan.TYPE_MEASURE, TtsSpan.TYPE_MONEY, TtsSpan.TYPE_DATE, TtsSpan.TYPE_TIME, + TtsSpan.TYPE_TELEPHONE -> true + else -> false + } + + /** Immutable mapping for this exact speech request, including any resumed suffix. */ + class Result(@JvmField val text: CharSequence, private val speechToSource: IntArray) { + fun sourceOffset(speechOffset: Int): Int = + speechToSource[speechOffset.coerceIn(0, speechToSource.lastIndex)] + } +} diff --git a/utils/src/main/java/com/google/android/accessibility/utils/output/SpeechControllerImpl.java b/utils/src/main/java/com/google/android/accessibility/utils/output/SpeechControllerImpl.java index f67f7dff4..12cbf5575 100644 --- a/utils/src/main/java/com/google/android/accessibility/utils/output/SpeechControllerImpl.java +++ b/utils/src/main/java/com/google/android/accessibility/utils/output/SpeechControllerImpl.java @@ -2191,6 +2191,9 @@ private boolean processNextFragmentInternal() { } } + // Punctuation and locale processing are complete. Keep the stored fragment unchanged. + text = iterator.prepareSpeechText(text); + final String logText = (text == null) ? null : String.format("\"%s\"", text.toString()); LogUtils.v( TAG, @@ -2552,9 +2555,12 @@ private void onFragmentStarted(String utteranceId) { private void onFragmentRangeStarted(String utteranceId, int start, int end) { int offset = 0; - if (currentFragmentIterator != null) { - currentFragmentIterator.onFragmentRangeStarted(utteranceId, start, end); - offset = currentFragmentIterator.getFeedbackItemOffset(); + FeedbackFragmentsIterator iterator = currentFragmentIterator; + if (iterator != null) { + start = iterator.sourceOffset(start); + end = iterator.sourceOffset(end); + iterator.onFragmentRangeStarted(utteranceId, start, end); + offset = iterator.getFeedbackItemOffset(); } final int utteranceIndex = SpeechControllerImpl.parseUtteranceId(utteranceId); onUtteranceRangeStarted(utteranceIndex, start + offset, end + offset); diff --git a/utils/src/test/java/com/google/android/accessibility/utils/output/FailoverNumbersAsDigitsTest.kt b/utils/src/test/java/com/google/android/accessibility/utils/output/FailoverNumbersAsDigitsTest.kt new file mode 100644 index 000000000..ed34fffd8 --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/output/FailoverNumbersAsDigitsTest.kt @@ -0,0 +1,210 @@ +/* + * Copyright 2026 Backtalk contributors + * + * 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.google.android.accessibility.utils.output + +import android.content.Context +import android.graphics.Typeface +import android.os.Bundle +import android.os.HandlerThread +import android.os.Looper +import android.speech.tts.TextToSpeech +import android.text.SpannableString +import android.text.Spanned +import android.text.style.LocaleSpan +import android.text.style.StyleSpan +import android.text.style.TtsSpan +import java.util.Locale +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.shadows.ShadowTextToSpeech +import org.robolectric.util.ReflectionHelpers +import org.robolectric.util.ReflectionHelpers.ClassParameter + +/** Exercises actual engine requests, including the suspended cache queue. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [26, 34], shadows = [FailoverNumbersAsDigitsTest.RecordingTts::class]) +class FailoverNumbersAsDigitsTest { + private lateinit var failover: FailoverTextToSpeech + + @Before fun setUp() { + val context = RuntimeEnvironment.getApplication() + failover = FailoverTextToSpeech(context, false) + ReflectionHelpers.setField(failover, "tts", TextToSpeech(context) {}) + ReflectionHelpers.setField(failover, "lowLatencyAudio", false) + ReflectionHelpers.setField(failover, "speakInPhrases", false) + VoiceProfiles.setActive(null) + NumbersAsDigits.setEnabled(true) + RecordingTts.calls.clear() + } + + @After fun tearDown() { + failover.shutdown() + NumbersAsDigits.setEnabled(false) + RecordingTts.calls.clear() + } + + @Test fun longDigitsArgumentIsNotRepeatedInEveryEngineChunk() { assertLongArgumentIsNotRepeated(false) } + @Test fun longTextArgumentIsNotRepeatedInEveryEngineChunk() { assertLongArgumentIsNotRepeated(true) } + + private fun assertLongArgumentIsNotRepeated(textArgument: Boolean) { + val original = "1234567890".repeat(300) + val source = SpannableString(original) + val instruction = if (textArgument) TtsSpan.TextBuilder(original).build() else TtsSpan.DigitsBuilder(original).build() + val language = LocaleSpan(Locale.US) + val style = StyleSpan(Typeface.BOLD) + source.setSpan(style, 0, original.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + source.setSpan(instruction, 0, original.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + source.setSpan(language, 0, original.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + val speech = NumbersAsDigits.process(source, true)!!.text + speakInChunks(speech) + assertTrue(RecordingTts.calls.size > 1) + val joined = StringBuilder() + for (call in RecordingTts.calls) { + assertTrue(call.length < TextToSpeech.getMaxSpeechInputLength()) + val spanned = call as Spanned + assertEquals(0, spanned.getSpans(0, call.length, TtsSpan::class.java).size) + assertEquals(0, spanned.getSpanStart(language)) + assertEquals(call.length, spanned.getSpanEnd(language)) + assertEquals(0, spanned.getSpanStart(style)) + assertEquals(call.length, spanned.getSpanEnd(style)) + joined.append(call) + } + assertEquals(speech.toString(), joined.toString()) + assertEquals(original, source.toString()) + assertEquals(0, source.getSpanStart(instruction)) + assertEquals(original.length, source.getSpanEnd(instruction)) + } + + @Test fun completeInstructionsSurviveButAnUnexpandedCrossingSpanDoesNot() { + val source = SpannableString("x".repeat(180) + ", " + "1".repeat(2500)) + val replacement = TtsSpan.TextBuilder("alternate wording").build() + val comma = TtsSpan.TextBuilder("comma").build() + val complete = TtsSpan.TextBuilder("fully retained").build() + source.setSpan(complete, 300, 310, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + source.setSpan(replacement, 0, 180, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + source.setSpan(comma, 180, 181, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + speakInChunks(NumbersAsDigits.process(source, true)!!.text) + var commas = 0 + var completeReplacements = 0 + for (call in RecordingTts.calls) { + val spanned = call as Spanned + assertEquals(-1, spanned.getSpanStart(replacement)) + if (spanned.getSpanStart(complete) >= 0) { + completeReplacements++ + assertEquals("fully retained", complete.args.getString(TtsSpan.ARG_TEXT)) + } + if (spanned.getSpanStart(comma) >= 0) { + commas++ + assertEquals(",", call.subSequence(spanned.getSpanStart(comma), spanned.getSpanEnd(comma)).toString()) + } + } + assertEquals(1, commas) + assertEquals(1, completeReplacements) + } + + @Test fun delimiterExtensionReachesTheEngineAsExplicitSpaces() { + val samples = mapOf("$1,500.00" to "$ 1 , 5 0 0 . 0 0", "-3.4" to "- 3 . 4", "R7,600.00" to "R 7 , 6 0 0 . 0 0") + for ((source, expected) in samples) { + RecordingTts.calls.clear() + speakInChunks(NumbersAsDigits.process(source, true)!!.text) + assertEquals(expected, RecordingTts.calls.single().toString()) + } + } + + @Test fun suspendedExpandedSpeechUsesTheLengthGuardEvenAfterSettingIsTurnedOff() { + val cache = FakeSpeakingCache(RuntimeEnvironment.getApplication()) + ReflectionHelpers.setField(failover, "speechCacheManager", cache) + val speech = NumbersAsDigits.process("1".repeat(3000), true)!!.text + enqueueBehindCache(speech) + assertTrue(RecordingTts.calls.isEmpty()) + NumbersAsDigits.setEnabled(false) + finishCachedSpeech() + assertTrue(RecordingTts.calls.size > 1) + val joined = StringBuilder() + for (call in RecordingTts.calls) { + assertTrue(call.length < TextToSpeech.getMaxSpeechInputLength()) + joined.append(call) + } + assertEquals(speech.toString(), joined.toString()) + } + + @Test fun suspendedUnpreparedSpeechKeepsItsExistingSingleRequestBehaviour() { + val cache = FakeSpeakingCache(RuntimeEnvironment.getApplication()) + ReflectionHelpers.setField(failover, "speechCacheManager", cache) + NumbersAsDigits.setEnabled(false) + ReflectionHelpers.setField(failover, "speakInPhrases", true) + val original = "word ".repeat(100) + enqueueBehindCache(original) + assertTrue(RecordingTts.calls.isEmpty()) + finishCachedSpeech() + assertEquals(1, RecordingTts.calls.size) + assertEquals(original, RecordingTts.calls.single().toString()) + } + + private fun speakInChunks(text: CharSequence) { + ReflectionHelpers.callInstanceMethod(failover, "speakInChunks", + ClassParameter.from(CharSequence::class.java, text), + ClassParameter.from(Int::class.javaPrimitiveType!!, TextToSpeech.QUEUE_ADD), + ClassParameter.from(Bundle::class.java, Bundle()), + ClassParameter.from(String::class.java, "test"), + ClassParameter.from(Locale::class.java, Locale.US)) + } + + private fun enqueueBehindCache(text: CharSequence) { + ReflectionHelpers.callInstanceMethod(failover, "speakWithCacheOrTts", + ClassParameter.from(String::class.java, "test"), + ClassParameter.from(CharSequence::class.java, text), + ClassParameter.from(Int::class.javaPrimitiveType!!, TextToSpeech.QUEUE_ADD), + ClassParameter.from(Locale::class.java, Locale.US), + ClassParameter.from(Bundle::class.java, Bundle())) + } + + private fun finishCachedSpeech() { + val listener = ReflectionHelpers.getField(failover, "playbackStateListener") + listener.onStateChanged("cached", SpeechCachePlayer.STATE_COMPLETED) + shadowOf(Looper.getMainLooper()).idle() + } + + private class FakeSpeakingCache(context: Context) : SpeechCacheManager(context, workerLooper()) { + // Cache completion is posted before playback finally clears its playing flag. + override fun isSpeaking() = true + companion object { + private fun workerLooper(): Looper = HandlerThread("test-cache").apply { start() }.looper + } + } + + @Implements(TextToSpeech::class) + class RecordingTts : ShadowTextToSpeech() { + @Implementation + override fun speak(text: CharSequence, queueMode: Int, params: Bundle?, id: String?): Int { + calls.add(text) + return TextToSpeech.SUCCESS + } + companion object { + val calls = mutableListOf() + } + } +} diff --git a/utils/src/test/java/com/google/android/accessibility/utils/output/NumbersAsDigitsTest.kt b/utils/src/test/java/com/google/android/accessibility/utils/output/NumbersAsDigitsTest.kt new file mode 100644 index 000000000..974fae553 --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/output/NumbersAsDigitsTest.kt @@ -0,0 +1,322 @@ +/* + * Copyright 2026 Backtalk contributors + * + * 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.google.android.accessibility.utils.output + +import android.graphics.Typeface +import android.speech.tts.TextToSpeech +import android.text.SpannableString +import android.text.Spanned +import android.text.style.LocaleSpan +import android.text.style.StyleSpan +import android.text.style.TtsSpan +import java.util.Locale +import org.junit.After +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [26, 34]) +class NumbersAsDigitsTest { + @After fun resetPreference() { NumbersAsDigits.setEnabled(false) } + + private fun spoken(source: String): String = NumbersAsDigits.process(source, true)?.text?.toString() ?: source + + @Test fun enabledReadsRunsAndLeadingZeros() { + assertEquals("2 8 3 4 8 2", spoken("283482")) + assertEquals("0 0 0 1 2 3", spoken("000123")) + assertEquals("Your code is 9 2 3 8 8 6", spoken("Your code is 923886")) + assertEquals("5 6 3 7 1 4 4 8 7 4 5 1 1 5 9 4 1 6 3 3", spoken("5637 1448 7451 1594 1633")) + } + + @Test fun contiguousTwentyDigitTokenIsSpaced() { + assertEquals("0 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8", spoken("00123456789012345678")) + } + + @Test fun disabledAndUnchangedInputBypassTransformation() { + for (source in listOf("", "ordinary text", "1", "1 2", "000123", "$1,500.00", "R7,600.00")) { + assertNull(NumbersAsDigits.process(source, false)) + } + for (source in listOf("", "ordinary text", "1", "1 2")) assertNull(NumbersAsDigits.process(source, true)) + assertNull(NumbersAsDigits.process(null, true)) + } + + @Test fun numericDelimitersAreSeparatedAndWhitespaceIsPreserved() { + assertEquals("- 1 2 , 3 4 . 5 6 / 7 8\n9 0\t1 2", spoken("-12,34.56 / 78\n90\t12")) + } + + @Test fun requestedDelimiterExamplesAreFormattedWithoutNumberInterpretation() { + assertEquals("$ 1 , 5 0 0 . 0 0", spoken("$1,500.00")) + assertEquals("3 , 5 . 2 5", spoken("3,5.25")) + assertEquals("- 3 . 4", spoken("-3.4")) + assertEquals("R 7 , 6 0 0 . 0 0", spoken("R7,600.00")) + assertEquals("+ 0 . 5", spoken("+0.5")) + assertEquals("− 3 . 4", spoken("−3.4")) + assertEquals("€ 1 , 2 0", spoken("€1,20")) + } + + @Test fun onlyDirectDigitBoundariesInTheSelectedCharacterSetAreChanged() { + assertEquals("Hello, world. R$, -- + .", spoken("Hello, world. R$, -- + .")) + assertEquals("A 1 2 B", spoken("A12B")) + assertEquals("USD 7 .", spoken("USD7.")) + assertEquals("(1 2)/3 4:5 6!", spoken("(12)/34:56!")) + assertEquals("$ 1 2 , text. Then 3 . End!", spoken("$12, text. Then 3. End!")) + assertEquals("1 2 , 3 4", spoken("12 , 34")) + } + + @Test fun unicodeDigitsAreSeparatedAtCodePointBoundaries() { + assertEquals("١ ٢ ٣", spoken("١٢٣")) + assertEquals("𝟙 𝟚", spoken("𝟙𝟚")) + assertEquals("😀1 2😀", spoken("😀12😀")) + } + + @Test fun positionsMapBackBeforeInsertedSpacesAndAfterTheNumber() { + val result = NumbersAsDigits.process("A 12 B", true)!! + assertEquals("A 1 2 B", result.text.toString()) + val expected = intArrayOf(0, 1, 2, 3, 3, 4, 5, 6) + expected.forEachIndexed { index, offset -> assertEquals(offset, result.sourceOffset(index)) } + assertEquals(0, result.sourceOffset(-1)) + assertEquals(6, result.sourceOffset(100)) + } + + @Test fun languageStyleAndPunctuationSpansAreRemappedWithoutMutatingSource() { + val original = SpannableString("12,34") + val language = LocaleSpan(Locale.FRENCH) + val style = StyleSpan(Typeface.BOLD) + val punctuation = TtsSpan.TextBuilder("comma").build() + original.setSpan(language, 0, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + original.setSpan(style, 1, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + original.setSpan(punctuation, 2, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + val speech = NumbersAsDigits.process(original, true)!!.text as Spanned + assertEquals("1 2 , 3 4", speech.toString()) + assertEquals(0, speech.getSpanStart(language)) + assertEquals(9, speech.getSpanEnd(language)) + assertEquals(2, speech.getSpanStart(style)) + assertEquals(3, speech.getSpanEnd(style)) + assertEquals(4, speech.getSpanStart(punctuation)) + assertEquals(5, speech.getSpanEnd(punctuation)) + assertEquals("12,34", original.toString()) + assertEquals(1, original.getSpanStart(style)) + assertEquals(2, original.getSpanEnd(style)) + } + + @Test fun delimiterSpansFromPunctuationProcessingKeepTheirExactCharacterRanges() { + val original = SpannableString("$1,500.00") + val language = LocaleSpan(Locale.US) + original.setSpan(language, 0, original.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + for (verbosity in listOf(SpeechCleanupUtils.ALL, SpeechCleanupUtils.MOST, SpeechCleanupUtils.SOME)) { + val source = SpannableString(original) + for (index in listOf(0, 2, 6)) { + val name = SpeechCleanupUtils.characterToName(RuntimeEnvironment.getApplication(), source[index], verbosity) + if (name != null) source.setSpan(TtsSpan.TextBuilder(name).build(), index, index + 1, + Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + } + val result = NumbersAsDigits.process(source, true)!! + val speech = result.text as Spanned + assertEquals("$ 1 , 5 0 0 . 0 0", speech.toString()) + assertEquals(17, speech.getSpanEnd(language)) + for (punctuation in source.getSpans(0, source.length, TtsSpan::class.java)) { + val index = source.getSpanStart(punctuation) + assertEquals(index * 2, speech.getSpanStart(punctuation)) + assertEquals(index * 2 + 1, speech.getSpanEnd(punctuation)) + assertEquals(Spanned.SPAN_INCLUSIVE_EXCLUSIVE, speech.getSpanFlags(punctuation)) + } + val expected = intArrayOf(0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9) + expected.forEachIndexed { index, offset -> assertEquals(offset, result.sourceOffset(index)) } + assertEquals("$1,500.00", source.toString()) + assertEquals(9, source.getSpanEnd(language)) + } + } + + @Test fun delimiterResumeKeepsOriginalCursorAndPreparesRemainingSuffix() { + NumbersAsDigits.setEnabled(true) + val fragment = FeedbackFragment("$1,500.00", null) + val iterator = iterator(fragment) + iterator.next() + assertEquals("$ 1 , 5 0 0 . 0 0", iterator.prepareSpeechText(fragment.text).toString()) + iterator.onFragmentRangeStarted("test", iterator.sourceOffset(4), iterator.sourceOffset(5)) + iterator.next() + assertEquals(",500.00", fragment.text.toString()) + assertEquals(2, iterator.feedbackItemOffset) + assertEquals(", 5 0 0 . 0 0", iterator.prepareSpeechText(fragment.text).toString()) + } + + @Test fun conflictingNumberSpanIsOverriddenOnlyInTheSpeechCopy() { + val original = SpannableString("000123") + val number = TtsSpan.CardinalBuilder("123").build() + original.setSpan(number, 0, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + val speech = NumbersAsDigits.process(original, true)!!.text as Spanned + assertEquals("0 0 0 1 2 3", speech.toString()) + assertEquals(-1, speech.getSpanStart(number)) + assertEquals(0, original.getSpanStart(number)) + assertEquals(6, original.getSpanEnd(number)) + } + + @Test fun existingDigitSpanUsedByColorAnnouncementsRemains() { + val original = SpannableString("00FF") + val digits = TtsSpan.DigitsBuilder("00FF").build() + original.setSpan(digits, 0, 4, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + val speech = NumbersAsDigits.process(original, true)!!.text as Spanned + assertEquals("0 0 FF", speech.toString()) + assertEquals(0, speech.getSpanStart(digits)) + assertEquals(6, speech.getSpanEnd(digits)) + assertEquals("00FF", digits.args.getString(TtsSpan.ARG_DIGITS)) + } + + @Test fun singleDigitAndExplicitReplacementSpansRemain() { + val original = SpannableString("123") + val oneDigit = TtsSpan.CardinalBuilder("2").build() + val replacement = TtsSpan.TextBuilder("existing speech replacement").build() + original.setSpan(oneDigit, 1, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + original.setSpan(replacement, 0, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + val speech = NumbersAsDigits.process(original, true)!!.text as Spanned + assertEquals(2, speech.getSpanStart(oneDigit)) + assertEquals(3, speech.getSpanEnd(oneDigit)) + assertEquals(0, speech.getSpanStart(replacement)) + assertEquals(5, speech.getSpanEnd(replacement)) + } + + @Test fun resumeUsesOriginalDigitPositionAndRebuildsSuffixMapping() { + NumbersAsDigits.setEnabled(true) + val fragment = FeedbackFragment("Code 00123", null) + val iterator = iterator(fragment) + assertSame(fragment, iterator.next()) + assertEquals("Code 0 0 1 2 3", iterator.prepareSpeechText(fragment.text).toString()) + val start = iterator.sourceOffset(7) + assertEquals(6, start) + iterator.onFragmentRangeStarted("test", start, iterator.sourceOffset(8)) + assertSame(fragment, iterator.next()) + assertEquals("0123", fragment.text.toString()) + assertEquals(6, iterator.feedbackItemOffset) + assertTrue(iterator.nextIsContinuation()) + assertEquals("0 1 2 3", iterator.prepareSpeechText(fragment.text).toString()) + assertEquals(2, iterator.sourceOffset(4)) + } + + @Test fun preferenceToggleDoesNotChangeMappingOfAlreadySubmittedSpeech() { + NumbersAsDigits.setEnabled(true) + val fragment = FeedbackFragment("123", null) + val iterator = iterator(fragment) + iterator.next() + iterator.prepareSpeechText(fragment.text) + val saved = iterator.deepCopy() + NumbersAsDigits.setEnabled(false) + assertEquals(2, iterator.sourceOffset(4)) + assertSame(fragment.text, iterator.prepareSpeechText(fragment.text)) + assertEquals(2, iterator.sourceOffset(2)) + assertEquals(2, saved.sourceOffset(4)) + } + + @Test fun resumedLastDigitDoesNotReplayTheOriginalDigitsArgument() { assertResumedArgumentIsNotReplayed(false) } + @Test fun resumedSuffixDoesNotReplayTheOriginalTextArgument() { assertResumedArgumentIsNotReplayed(true) } + + private fun assertResumedArgumentIsNotReplayed(textArgument: Boolean) { + NumbersAsDigits.setEnabled(true) + val original = SpannableString("123456") + val instruction = if (textArgument) TtsSpan.TextBuilder("123456").build() else TtsSpan.DigitsBuilder("123456").build() + val language = LocaleSpan(Locale.US) + original.setSpan(instruction, 0, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + original.setSpan(language, 0, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + val fragment = FeedbackFragment(original, null) + val iterator = iterator(fragment) + iterator.next() + assertEquals("1 2 3 4 5 6", iterator.prepareSpeechText(fragment.text).toString()) + assertEquals(2, (fragment.text as Spanned).getSpans(0, 6, Any::class.java).size) + iterator.onFragmentRangeStarted("test", iterator.sourceOffset(10), iterator.sourceOffset(11)) + iterator.next() + assertEquals(5, iterator.feedbackItemOffset) + assertEquals("6", fragment.text.toString()) + val speech = iterator.prepareSpeechText(fragment.text) as Spanned + assertEquals("6", speech.toString()) + assertEquals(0, speech.getSpans(0, 1, TtsSpan::class.java).size) + assertEquals(0, speech.getSpanStart(language)) + assertEquals(1, speech.getSpanEnd(language)) + assertEquals(1, iterator.sourceOffset(1)) + assertEquals(0, original.getSpanStart(instruction)) + assertEquals(6, original.getSpanEnd(instruction)) + } + + @Test fun resumeAtAReplacementBoundaryRetainsTheCompleteInstruction() { + NumbersAsDigits.setEnabled(true) + val source = SpannableString("12 ABC") + val complete = TtsSpan.TextBuilder("fully retained").build() + source.setSpan(complete, 3, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + val fragment = FeedbackFragment(source, null) + val iterator = iterator(fragment) + iterator.next() + assertEquals("1 2 ABC", iterator.prepareSpeechText(fragment.text).toString()) + iterator.onFragmentRangeStarted("test", iterator.sourceOffset(4), iterator.sourceOffset(7)) + iterator.next() + val remaining = iterator.prepareSpeechText(fragment.text) as Spanned + assertEquals("ABC", remaining.toString()) + assertEquals(3, iterator.feedbackItemOffset) + assertEquals(0, remaining.getSpanStart(complete)) + assertEquals(3, remaining.getSpanEnd(complete)) + assertEquals("fully retained", complete.args.getString(TtsSpan.ARG_TEXT)) + } + + @Test fun aPlainStringFragmentCanResumeAfterDigitPreparation() { + NumbersAsDigits.setEnabled(true) + val fragment = FeedbackFragment("123456", null) + fragment.text = "123456" + val iterator = iterator(fragment) + iterator.next() + iterator.prepareSpeechText(fragment.text) + iterator.onFragmentRangeStarted("test", iterator.sourceOffset(10), iterator.sourceOffset(11)) + iterator.next() + assertEquals("6", iterator.prepareSpeechText(fragment.text).toString()) + assertEquals(5, iterator.feedbackItemOffset) + } + + @Test fun disabledResumeKeepsTheExistingSpanAndTextBehaviour() { + NumbersAsDigits.setEnabled(false) + val source = SpannableString("123456") + val instruction = TtsSpan.TextBuilder("existing replacement").build() + source.setSpan(instruction, 0, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + val fragment = FeedbackFragment(source, null) + val iterator = iterator(fragment) + iterator.next() + assertSame(fragment.text, iterator.prepareSpeechText(fragment.text)) + iterator.onFragmentRangeStarted("test", 5, 6) + iterator.next() + val suffix = iterator.prepareSpeechText(fragment.text) as Spanned + assertEquals("6", suffix.toString()) + assertEquals(0, suffix.getSpanStart(instruction)) + assertEquals(1, suffix.getSpanEnd(instruction)) + assertEquals("existing replacement", instruction.args.getString(TtsSpan.ARG_TEXT)) + } + + @Test fun expandedTextCanBeChunkedWithinTheEngineLimit() { + val original = "1".repeat(TextToSpeech.getMaxSpeechInputLength() - 1) + val speech = NumbersAsDigits.process(original, true)!!.text + assertTrue(speech.length > TextToSpeech.getMaxSpeechInputLength()) + val starts = SpeechChunker.chunkStarts(speech, Locale.US) + assertTrue(starts.size > 1) + val joined = StringBuilder() + for (i in starts.indices) { + val end = if (i + 1 < starts.size) starts[i + 1] else speech.length + assertTrue(end - starts[i] < TextToSpeech.getMaxSpeechInputLength()) + joined.append(speech.subSequence(starts[i], end)) + } + assertEquals(speech.toString(), joined.toString()) + } + + private fun iterator(fragment: FeedbackFragment) = FeedbackFragmentsIterator(listOf(fragment).iterator(), "test") +}