From 2bfc10d9a32e393193262befeba2766925d3f0af Mon Sep 17 00:00:00 2001 From: MG Bledig Date: Tue, 3 Nov 2026 11:14:13 +0200 Subject: [PATCH 1/2] Add optional speak numbers as digits setting --- differences.md | 27 ++ lint.xml | 2 + .../talkback/TalkBackService.java | 5 + .../src/main/res/values/donottranslate.xml | 2 + talkback/src/main/res/values/strings.xml | 4 + .../main/res/xml/verbosity_preferences.xml | 7 + .../res/xml-watch/verbosity_preferences.xml | 8 + .../utils/output/FailoverTextToSpeech.java | 31 +- .../utils/output/FeedbackFragment.java | 6 +- .../output/FeedbackFragmentsIterator.java | 25 +- .../utils/output/NumbersAsDigits.java | 178 +++++++++ .../utils/output/SpeechControllerImpl.java | 12 +- .../output/FailoverNumbersAsDigitsTest.java | 221 +++++++++++ .../utils/output/NumbersAsDigitsTest.java | 355 ++++++++++++++++++ 14 files changed, 870 insertions(+), 13 deletions(-) create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/output/NumbersAsDigits.java create mode 100644 utils/src/test/java/com/google/android/accessibility/utils/output/FailoverNumbersAsDigitsTest.java create mode 100644 utils/src/test/java/com/google/android/accessibility/utils/output/NumbersAsDigitsTest.java diff --git a/differences.md b/differences.md index 2565d0a87..c251c7bd8 100644 --- a/differences.md +++ b/differences.md @@ -209,6 +209,33 @@ 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. 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. + +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..5b8edb169 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 consecutive digits individually, including leading zeros 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" /> + + + + + + 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.java b/utils/src/main/java/com/google/android/accessibility/utils/output/NumbersAsDigits.java new file mode 100644 index 000000000..21e3cf435 --- /dev/null +++ b/utils/src/main/java/com/google/android/accessibility/utils/output/NumbersAsDigits.java @@ -0,0 +1,178 @@ +/* + * 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; +import androidx.annotation.Nullable; + +/** Separates digits and adjacent delimiters in a speech-only copy, retaining source positions. */ +public final class NumbersAsDigits { + private static volatile boolean enabled; + + public static void setEnabled(boolean value) { + enabled = value; + } + + @Nullable + static Result process(@Nullable CharSequence source) { + return process(source, enabled); + } + + /** Returns null when no change is needed, so disabled speech keeps the original object. */ + @Nullable + static Result process(@Nullable CharSequence source, boolean enabled) { + if (!enabled || source == null || source.length() < 2) { + return null; + } + int length = source.length(); + boolean[] insertBefore = new boolean[length + 1]; + int spaces = 0; + int previous = -1; + for (int i = 0; i < length; ) { + int codePoint = Character.codePointAt(source, i); + if ((Character.isDigit(previous) && isNumericNeighbour(codePoint)) + || (Character.isDigit(codePoint) && isNumericNeighbour(previous))) { + insertBefore[i] = true; + spaces++; + } + previous = codePoint; + i += Character.charCount(codePoint); + } + if (spaces == 0) { + return null; + } + + StringBuilder output = new StringBuilder(length + spaces); + int[] sourceToSpeech = new int[length + 1]; + int[] speechToSource = new int[length + spaces + 1]; + for (int i = 0; i < length; i++) { + if (insertBefore[i]) { + output.append(' '); + speechToSource[output.length()] = i; + } + sourceToSpeech[i] = output.length(); + output.append(source.charAt(i)); + speechToSource[output.length()] = i + 1; + } + sourceToSpeech[length] = output.length(); + CharSequence speech = output.toString(); + if (source instanceof Spanned spanned) { + SpannableString copy = new SpannableString(speech); + for (Object span : spanned.getSpans(0, length, Object.class)) { + int start = spanned.getSpanStart(span); + int end = spanned.getSpanEnd(span); + int mappedStart = sourceToSpeech[start]; + int mappedEnd = sourceToSpeech[end] - (insertBefore[end] ? 1 : 0); + // Number interpretation would override the spaces. Keep explicit text replacements, + // punctuation, language, formatting and digit-by-digit spans. Override number readings. + if (span instanceof TtsSpan ttsSpan && conflictsWithSpacing(ttsSpan) + && mappedEnd - mappedStart > end - start) { + continue; + } + copy.setSpan(span, mappedStart, Math.max(mappedStart, mappedEnd), + spanned.getSpanFlags(span)); + } + speech = copy; + } + return new Result(protect(speech), speechToSource); + } + + /** Tags a private copy so protection follows prepared speech through queues and resume. */ + static CharSequence protect(CharSequence text) { + if (isPreparedText(text)) { + return text; + } + SpannableString copy = new SpannableString(text); + copy.setSpan(new PreparedSpeechSpan(), 0, copy.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + return copy; + } + + static boolean isPreparedText(CharSequence text) { + return text instanceof Spanned spanned + && spanned.getSpans(0, text.length(), PreparedSpeechSpan.class).length != 0; + } + + /** + * Cuts a prepared speech copy without repeating an instruction for the unsliced text. + * Arbitrary replacement arguments cannot be divided using character offsets, so a partial + * instruction falls back to the visible text. Complete instructions and other spans survive. + */ + static CharSequence subSequenceForSpeech(CharSequence text, int start, int end) { + return subSequenceForSpeech(text, start, end, isPreparedText(text)); + } + + static CharSequence subSequenceForSpeech( + CharSequence text, int start, int end, boolean preparedSpeech) { + CharSequence slice = text.subSequence(start, end); + if (!preparedSpeech || !(text instanceof Spanned spanned)) { + return slice; + } + SpannableString copy = null; + for (TtsSpan span : spanned.getSpans(start, end, TtsSpan.class)) { + if (!span.getArgs().isEmpty() + && (spanned.getSpanStart(span) < start || spanned.getSpanEnd(span) > end)) { + if (copy == null) { + copy = new SpannableString(slice); + } + copy.removeSpan(span); + } + } + return copy == null ? slice : copy; + } + + /** Internal metadata only; never attached to accessibility-node text. */ + private static final class PreparedSpeechSpan {} + + /** Character boundaries only: no currency, decimal, sign or identifier recognition. */ + private static boolean isNumericNeighbour(int codePoint) { + return Character.isLetterOrDigit(codePoint) + || Character.getType(codePoint) == Character.CURRENCY_SYMBOL + || codePoint == ',' + || codePoint == '.' + || codePoint == '+' + || codePoint == '-' + || codePoint == '\u2212'; + } + + private static boolean conflictsWithSpacing(TtsSpan span) { + return switch (span.getType()) { + case 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; + default -> false; + }; + } + + /** Immutable mapping for this exact speech request, including a resumed fragment's suffix. */ + static final class Result { + final CharSequence text; + private final int[] speechToSource; + + Result(CharSequence text, int[] speechToSource) { + this.text = text; + this.speechToSource = speechToSource; + } + + int sourceOffset(int speechOffset) { + return speechToSource[Math.max(0, Math.min(speechOffset, speechToSource.length - 1))]; + } + } + + private NumbersAsDigits() {} +} 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.java b/utils/src/test/java/com/google/android/accessibility/utils/output/FailoverNumbersAsDigitsTest.java new file mode 100644 index 000000000..f830b04cb --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/output/FailoverNumbersAsDigitsTest.java @@ -0,0 +1,221 @@ +/* + * 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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.robolectric.Shadows.shadowOf; + +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.ArrayList; +import java.util.List; +import java.util.Locale; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +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 the actual engine requests, including the suspended cache queue. */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = {26, 34}, shadows = FailoverNumbersAsDigitsTest.RecordingTts.class) +public class FailoverNumbersAsDigitsTest { + private FailoverTextToSpeech failover; + + @Before public void setUp() { + Context context = RuntimeEnvironment.getApplication(); + failover = new FailoverTextToSpeech(context, false); + ReflectionHelpers.setField(failover, "tts", new TextToSpeech(context, status -> {})); + ReflectionHelpers.setField(failover, "lowLatencyAudio", false); + ReflectionHelpers.setField(failover, "speakInPhrases", false); + VoiceProfiles.setActive(null); + NumbersAsDigits.setEnabled(true); + RecordingTts.calls.clear(); + } + + @After public void tearDown() { + failover.shutdown(); + NumbersAsDigits.setEnabled(false); + RecordingTts.calls.clear(); + } + + @Test public void longDigitsArgumentIsNotRepeatedInEveryEngineChunk() { + assertLongArgumentIsNotRepeated(false); + } + + @Test public void longTextArgumentIsNotRepeatedInEveryEngineChunk() { + assertLongArgumentIsNotRepeated(true); + } + + private void assertLongArgumentIsNotRepeated(boolean textArgument) { + String original = "1234567890".repeat(300); + SpannableString source = new SpannableString(original); + TtsSpan instruction = textArgument ? new TtsSpan.TextBuilder(original).build() + : new TtsSpan.DigitsBuilder(original).build(); + LocaleSpan language = new LocaleSpan(Locale.US); + StyleSpan style = new 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); + CharSequence speech = NumbersAsDigits.process(source, true).text; + speakInChunks(speech); + assertTrue(RecordingTts.calls.size() > 1); + StringBuilder joined = new StringBuilder(); + for (CharSequence call : RecordingTts.calls) { + assertTrue(call.length() < TextToSpeech.getMaxSpeechInputLength()); + Spanned spanned = (Spanned) call; + assertEquals(0, spanned.getSpans(0, call.length(), TtsSpan.class).length); + 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 public void completeInstructionsSurviveButAnUnexpandedCrossingSpanDoesNot() { + SpannableString source = new SpannableString("x".repeat(180) + ", " + "1".repeat(2500)); + TtsSpan replacement = new TtsSpan.TextBuilder("alternate wording").build(); + TtsSpan comma = new TtsSpan.TextBuilder("comma").build(); + TtsSpan complete = new 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); + int commas = 0; + int completeReplacements = 0; + for (CharSequence call : RecordingTts.calls) { + Spanned spanned = (Spanned) call; + assertEquals(-1, spanned.getSpanStart(replacement)); + if (spanned.getSpanStart(complete) >= 0) { + completeReplacements++; + assertEquals("fully retained", complete.getArgs().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 public void suspendedExpandedSpeechUsesTheLengthGuardEvenAfterSettingIsTurnedOff() { + FakeSpeakingCache cache = new FakeSpeakingCache(RuntimeEnvironment.getApplication()); + ReflectionHelpers.setField(failover, "speechCacheManager", cache); + CharSequence speech = NumbersAsDigits.process("1".repeat(3000), true).text; + enqueueBehindCache(speech); + assertTrue(RecordingTts.calls.isEmpty()); + NumbersAsDigits.setEnabled(false); + finishCachedSpeech(); + assertTrue(RecordingTts.calls.size() > 1); + StringBuilder joined = new StringBuilder(); + for (CharSequence call : RecordingTts.calls) { + assertTrue(call.length() < TextToSpeech.getMaxSpeechInputLength()); + joined.append(call); + } + assertEquals(speech.toString(), joined.toString()); + } + + @Test public void suspendedUnpreparedSpeechKeepsItsExistingSingleRequestBehaviour() { + FakeSpeakingCache cache = new FakeSpeakingCache(RuntimeEnvironment.getApplication()); + ReflectionHelpers.setField(failover, "speechCacheManager", cache); + NumbersAsDigits.setEnabled(false); + ReflectionHelpers.setField(failover, "speakInPhrases", true); + String original = "word ".repeat(100); + enqueueBehindCache(original); + assertTrue(RecordingTts.calls.isEmpty()); + finishCachedSpeech(); + assertEquals(1, RecordingTts.calls.size()); + assertEquals(original, RecordingTts.calls.get(0).toString()); + } + + private void speakInChunks(CharSequence text) { + ReflectionHelpers.callInstanceMethod(failover, "speakInChunks", + ClassParameter.from(CharSequence.class, text), + ClassParameter.from(int.class, TextToSpeech.QUEUE_ADD), + ClassParameter.from(Bundle.class, new Bundle()), + ClassParameter.from(String.class, "test"), + ClassParameter.from(Locale.class, Locale.US)); + } + + private void enqueueBehindCache(CharSequence text) { + ReflectionHelpers.callInstanceMethod(failover, "speakWithCacheOrTts", + ClassParameter.from(String.class, "test"), + ClassParameter.from(CharSequence.class, text), + ClassParameter.from(int.class, TextToSpeech.QUEUE_ADD), + ClassParameter.from(Locale.class, Locale.US), + ClassParameter.from(Bundle.class, new Bundle())); + } + + private void finishCachedSpeech() { + SpeechCachePlayer.PlaybackStateListener listener = + ReflectionHelpers.getField(failover, "playbackStateListener"); + listener.onStateChanged("cached", SpeechCachePlayer.STATE_COMPLETED); + shadowOf(Looper.getMainLooper()).idle(); + } + + private static final class FakeSpeakingCache extends SpeechCacheManager { + FakeSpeakingCache(Context context) { + super(context, workerLooper()); + } + + private static Looper workerLooper() { + HandlerThread worker = new HandlerThread("test-cache"); + worker.start(); + return worker.getLooper(); + } + + @Override public boolean isSpeaking() { + // Cache completion is posted before playback finally clears its playing flag. + return true; + } + } + + @Implements(TextToSpeech.class) + public static class RecordingTts extends ShadowTextToSpeech { + static final List calls = new ArrayList<>(); + + @Implementation + @Override protected int speak(CharSequence text, int queueMode, Bundle params, String id) { + calls.add(text); + return TextToSpeech.SUCCESS; + } + } +} diff --git a/utils/src/test/java/com/google/android/accessibility/utils/output/NumbersAsDigitsTest.java b/utils/src/test/java/com/google/android/accessibility/utils/output/NumbersAsDigitsTest.java new file mode 100644 index 000000000..5a8a21dce --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/output/NumbersAsDigitsTest.java @@ -0,0 +1,355 @@ +/* + * 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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +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.Arrays; +import java.util.List; +import java.util.Locale; +import org.junit.After; +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}) +public class NumbersAsDigitsTest { + @After public void resetPreference() { + NumbersAsDigits.setEnabled(false); + } + + private String spoken(String source) { + NumbersAsDigits.Result result = NumbersAsDigits.process(source, true); + return result == null ? source : result.text.toString(); + } + + @Test public void 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 public void disabledAndUnchangedInputBypassTransformation() { + for (String source : Arrays.asList("", "ordinary text", "1", "1 2", "000123", "$1,500.00", "R7,600.00")) { + assertNull(NumbersAsDigits.process(source, false)); + } + for (String source : Arrays.asList("", "ordinary text", "1", "1 2")) { + assertNull(NumbersAsDigits.process(source, true)); + } + assertNull(NumbersAsDigits.process(null, true)); + } + + @Test public void numericDelimitersAreSeparatedAndWhitespaceIsPreserved() { + assertEquals("- 1 2 , 3 4 . 5 6 / 7 8\n9 0\t1 2", + spoken("-12,34.56 / 78\n90\t12")); + } + + @Test public void 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 public void 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 public void unicodeDigitsAreSeparatedAtCodePointBoundaries() { + assertEquals("١ ٢ ٣", spoken("١٢٣")); + assertEquals("𝟙 𝟚", spoken("𝟙𝟚")); + assertEquals("😀1 2😀", spoken("😀12😀")); + } + + @Test public void positionsMapBackBeforeInsertedSpacesAndAfterTheNumber() { + NumbersAsDigits.Result result = NumbersAsDigits.process("A 12 B", true); + assertEquals("A 1 2 B", result.text.toString()); + int[] expected = {0, 1, 2, 3, 3, 4, 5, 6}; + for (int i = 0; i < expected.length; i++) { + assertEquals(expected[i], result.sourceOffset(i)); + } + assertEquals(0, result.sourceOffset(-1)); + assertEquals(6, result.sourceOffset(100)); + } + + @Test public void languageStyleAndPunctuationSpansAreRemappedWithoutMutatingSource() { + SpannableString original = new SpannableString("12,34"); + LocaleSpan language = new LocaleSpan(Locale.FRENCH); + StyleSpan style = new StyleSpan(Typeface.BOLD); + TtsSpan punctuation = new 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); + Spanned speech = (Spanned) NumbersAsDigits.process(original, true).text; + 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 public void delimiterSpansFromPunctuationProcessingKeepTheirExactCharacterRanges() { + SpannableString original = new SpannableString("$1,500.00"); + LocaleSpan language = new LocaleSpan(Locale.US); + original.setSpan(language, 0, original.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + for (int verbosity : new int[] {SpeechCleanupUtils.ALL, SpeechCleanupUtils.MOST, + SpeechCleanupUtils.SOME}) { + SpannableString source = new SpannableString(original); + // makeSpeakablePunctuation uses the same lookup and single-character TTS spans. + for (int index : new int[] {0, 2, 6}) { + String spokenName = SpeechCleanupUtils.characterToName( + RuntimeEnvironment.getApplication(), source.charAt(index), verbosity); + if (spokenName != null) { + source.setSpan(new TtsSpan.TextBuilder(spokenName).build(), index, index + 1, + Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + } + } + NumbersAsDigits.Result result = NumbersAsDigits.process(source, true); + Spanned speech = (Spanned) result.text; + assertEquals("$ 1 , 5 0 0 . 0 0", speech.toString()); + assertEquals(17, speech.getSpanEnd(language)); + for (TtsSpan punctuation : source.getSpans(0, source.length(), TtsSpan.class)) { + int 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)); + } + int[] expected = {0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9}; + for (int i = 0; i < expected.length; i++) { + assertEquals(expected[i], result.sourceOffset(i)); + } + assertEquals("$1,500.00", source.toString()); + assertEquals(9, source.getSpanEnd(language)); + } + } + + @Test public void delimiterResumeKeepsOriginalCursorAndPreparesRemainingSuffix() { + NumbersAsDigits.setEnabled(true); + FeedbackFragment fragment = new FeedbackFragment("$1,500.00", null); + FeedbackFragmentsIterator iterator = + new FeedbackFragmentsIterator(Arrays.asList(fragment).iterator(), "test"); + iterator.next(); + assertEquals("$ 1 , 5 0 0 . 0 0", iterator.prepareSpeechText(fragment.getText()).toString()); + iterator.onFragmentRangeStarted("test", iterator.sourceOffset(4), iterator.sourceOffset(5)); + iterator.next(); + assertEquals(",500.00", fragment.getText().toString()); + assertEquals(2, iterator.getFeedbackItemOffset()); + assertEquals(", 5 0 0 . 0 0", iterator.prepareSpeechText(fragment.getText()).toString()); + } + + @Test public void conflictingNumberSpanIsOverriddenOnlyInTheSpeechCopy() { + SpannableString original = new SpannableString("000123"); + TtsSpan number = new TtsSpan.CardinalBuilder("123").build(); + original.setSpan(number, 0, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + Spanned speech = (Spanned) NumbersAsDigits.process(original, true).text; + 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 public void existingDigitSpanUsedByColorAnnouncementsRemains() { + SpannableString original = new SpannableString("00FF"); + TtsSpan digits = new TtsSpan.DigitsBuilder("00FF").build(); + original.setSpan(digits, 0, 4, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + Spanned speech = (Spanned) NumbersAsDigits.process(original, true).text; + assertEquals("0 0 FF", speech.toString()); + assertEquals(0, speech.getSpanStart(digits)); + assertEquals(6, speech.getSpanEnd(digits)); + assertEquals("00FF", digits.getArgs().getString(TtsSpan.ARG_DIGITS)); + } + + @Test public void singleDigitAndExplicitReplacementSpansRemain() { + SpannableString original = new SpannableString("123"); + TtsSpan oneDigit = new TtsSpan.CardinalBuilder("2").build(); + TtsSpan replacement = new TtsSpan.TextBuilder("existing speech replacement").build(); + original.setSpan(oneDigit, 1, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + original.setSpan(replacement, 0, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + Spanned speech = (Spanned) NumbersAsDigits.process(original, true).text; + assertEquals(2, speech.getSpanStart(oneDigit)); + assertEquals(3, speech.getSpanEnd(oneDigit)); + assertEquals(0, speech.getSpanStart(replacement)); + assertEquals(5, speech.getSpanEnd(replacement)); + } + + @Test public void resumeUsesOriginalDigitPositionAndRebuildsSuffixMapping() { + NumbersAsDigits.setEnabled(true); + FeedbackFragment fragment = new FeedbackFragment("Code 00123", null); + FeedbackFragmentsIterator iterator = + new FeedbackFragmentsIterator(Arrays.asList(fragment).iterator(), "test"); + assertSame(fragment, iterator.next()); + assertEquals("Code 0 0 1 2 3", iterator.prepareSpeechText(fragment.getText()).toString()); + // The engine begins the second zero at speech offset 7, original offset 6. + int start = iterator.sourceOffset(7); + assertEquals(6, start); + iterator.onFragmentRangeStarted("test", start, iterator.sourceOffset(8)); + assertSame(fragment, iterator.next()); + assertEquals("0123", fragment.getText().toString()); + assertEquals(6, iterator.getFeedbackItemOffset()); + assertTrue(iterator.nextIsContinuation()); + assertEquals("0 1 2 3", iterator.prepareSpeechText(fragment.getText()).toString()); + assertEquals(2, iterator.sourceOffset(4)); + } + + @Test public void preferenceToggleDoesNotChangeMappingOfAlreadySubmittedSpeech() { + NumbersAsDigits.setEnabled(true); + FeedbackFragment fragment = new FeedbackFragment("123", null); + FeedbackFragmentsIterator iterator = + new FeedbackFragmentsIterator(Arrays.asList(fragment).iterator(), "test"); + iterator.next(); + iterator.prepareSpeechText(fragment.getText()); + FeedbackFragmentsIterator saved = iterator.deepCopy(); + NumbersAsDigits.setEnabled(false); + assertEquals(2, iterator.sourceOffset(4)); + assertSame(fragment.getText(), iterator.prepareSpeechText(fragment.getText())); + assertEquals(2, iterator.sourceOffset(2)); + assertEquals(2, saved.sourceOffset(4)); + } + + @Test public void resumedLastDigitDoesNotReplayTheOriginalDigitsArgument() { + assertResumedArgumentIsNotReplayed(false); + } + + @Test public void resumedSuffixDoesNotReplayTheOriginalTextArgument() { + assertResumedArgumentIsNotReplayed(true); + } + + private void assertResumedArgumentIsNotReplayed(boolean textArgument) { + NumbersAsDigits.setEnabled(true); + SpannableString original = new SpannableString("123456"); + TtsSpan instruction = textArgument ? new TtsSpan.TextBuilder("123456").build() + : new TtsSpan.DigitsBuilder("123456").build(); + LocaleSpan language = new LocaleSpan(Locale.US); + original.setSpan(instruction, 0, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + original.setSpan(language, 0, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + FeedbackFragment fragment = new FeedbackFragment(original, null); + FeedbackFragmentsIterator iterator = + new FeedbackFragmentsIterator(Arrays.asList(fragment).iterator(), "test"); + iterator.next(); + assertEquals("1 2 3 4 5 6", iterator.prepareSpeechText(fragment.getText()).toString()); + // Preparing speech must not attach private metadata to stored source/history text. + assertEquals(2, ((Spanned) fragment.getText()).getSpans(0, 6, Object.class).length); + iterator.onFragmentRangeStarted("test", iterator.sourceOffset(10), iterator.sourceOffset(11)); + iterator.next(); + assertEquals(5, iterator.getFeedbackItemOffset()); + assertEquals("6", fragment.getText().toString()); + Spanned speech = (Spanned) iterator.prepareSpeechText(fragment.getText()); + assertEquals("6", speech.toString()); + assertEquals(0, speech.getSpans(0, 1, TtsSpan.class).length); + 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 public void resumeAtAReplacementBoundaryRetainsTheCompleteInstruction() { + NumbersAsDigits.setEnabled(true); + SpannableString source = new SpannableString("12 ABC"); + TtsSpan complete = new TtsSpan.TextBuilder("fully retained").build(); + source.setSpan(complete, 3, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + FeedbackFragment fragment = new FeedbackFragment(source, null); + FeedbackFragmentsIterator iterator = + new FeedbackFragmentsIterator(Arrays.asList(fragment).iterator(), "test"); + iterator.next(); + assertEquals("1 2 ABC", iterator.prepareSpeechText(fragment.getText()).toString()); + iterator.onFragmentRangeStarted("test", iterator.sourceOffset(4), iterator.sourceOffset(7)); + iterator.next(); + Spanned remaining = (Spanned) iterator.prepareSpeechText(fragment.getText()); + assertEquals("ABC", remaining.toString()); + assertEquals(3, iterator.getFeedbackItemOffset()); + assertEquals(0, remaining.getSpanStart(complete)); + assertEquals(3, remaining.getSpanEnd(complete)); + assertEquals("fully retained", complete.getArgs().getString(TtsSpan.ARG_TEXT)); + } + + @Test public void aPlainStringFragmentCanResumeAfterDigitPreparation() { + NumbersAsDigits.setEnabled(true); + FeedbackFragment fragment = new FeedbackFragment("123456", null); + fragment.setText("123456"); + FeedbackFragmentsIterator iterator = + new FeedbackFragmentsIterator(Arrays.asList(fragment).iterator(), "test"); + iterator.next(); + iterator.prepareSpeechText(fragment.getText()); + iterator.onFragmentRangeStarted("test", iterator.sourceOffset(10), iterator.sourceOffset(11)); + iterator.next(); + assertEquals("6", iterator.prepareSpeechText(fragment.getText()).toString()); + assertEquals(5, iterator.getFeedbackItemOffset()); + } + + @Test public void disabledResumeKeepsTheExistingSpanAndTextBehaviour() { + NumbersAsDigits.setEnabled(false); + SpannableString source = new SpannableString("123456"); + TtsSpan instruction = new TtsSpan.TextBuilder("existing replacement").build(); + source.setSpan(instruction, 0, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + FeedbackFragment fragment = new FeedbackFragment(source, null); + FeedbackFragmentsIterator iterator = + new FeedbackFragmentsIterator(Arrays.asList(fragment).iterator(), "test"); + iterator.next(); + assertSame(fragment.getText(), iterator.prepareSpeechText(fragment.getText())); + iterator.onFragmentRangeStarted("test", 5, 6); + iterator.next(); + Spanned suffix = (Spanned) iterator.prepareSpeechText(fragment.getText()); + assertEquals("6", suffix.toString()); + assertEquals(0, suffix.getSpanStart(instruction)); + assertEquals(1, suffix.getSpanEnd(instruction)); + assertEquals("existing replacement", instruction.getArgs().getString(TtsSpan.ARG_TEXT)); + } + + @Test public void expandedTextCanBeChunkedWithinTheEngineLimit() { + String original = "1".repeat(TextToSpeech.getMaxSpeechInputLength() - 1); + CharSequence speech = NumbersAsDigits.process(original, true).text; + assertTrue(speech.length() > TextToSpeech.getMaxSpeechInputLength()); + List starts = SpeechChunker.chunkStarts(speech, Locale.US); + assertTrue(starts.size() > 1); + StringBuilder joined = new StringBuilder(); + for (int i = 0; i < starts.size(); i++) { + int end = i + 1 < starts.size() ? starts.get(i + 1) : speech.length(); + assertTrue(end - starts.get(i) < TextToSpeech.getMaxSpeechInputLength()); + joined.append(speech.subSequence(starts.get(i), end)); + } + assertEquals(speech.toString(), joined.toString()); + } +} From 6c62cca24516f0c58346ac14ee1e23d57de84512 Mon Sep 17 00:00:00 2001 From: MG Bledig Date: Sat, 7 Nov 2026 21:59:59 +0200 Subject: [PATCH 2/2] Clarify optional digit and delimiter speech Keep the opt-in spacing behavior tested on the Oppo with Google TTS and Vocalizer Tessa. Align the setting summary with the adjacent letters, currency symbols and numeric punctuation it separates. Move this feature's own helper and tests to Kotlin without changing the algorithm, add preference and engine-submission tests, and retain all original regression cases. Leave Google's Java files unchanged. Native digits spans worked in the standalone Google test but did not reliably give individual digits with the tested Tessa setup. Spacing was more predictable in those tests; neither approach promises identical pronunciation across engines, punctuation settings or voices. --- differences.md | 10 +- talkback/src/main/res/values/strings.xml | 4 +- .../base/NumbersAsDigitsPreferenceTest.kt | 66 ++++ .../utils/output/NumbersAsDigits.java | 178 --------- .../utils/output/NumbersAsDigits.kt | 142 +++++++ .../output/FailoverNumbersAsDigitsTest.java | 221 ----------- .../output/FailoverNumbersAsDigitsTest.kt | 210 +++++++++++ .../utils/output/NumbersAsDigitsTest.java | 355 ------------------ .../utils/output/NumbersAsDigitsTest.kt | 322 ++++++++++++++++ 9 files changed, 750 insertions(+), 758 deletions(-) create mode 100644 talkback/src/test/java/com/google/android/accessibility/talkback/preference/base/NumbersAsDigitsPreferenceTest.kt delete mode 100644 utils/src/main/java/com/google/android/accessibility/utils/output/NumbersAsDigits.java create mode 100644 utils/src/main/java/com/google/android/accessibility/utils/output/NumbersAsDigits.kt delete mode 100644 utils/src/test/java/com/google/android/accessibility/utils/output/FailoverNumbersAsDigitsTest.java create mode 100644 utils/src/test/java/com/google/android/accessibility/utils/output/FailoverNumbersAsDigitsTest.kt delete mode 100644 utils/src/test/java/com/google/android/accessibility/utils/output/NumbersAsDigitsTest.java create mode 100644 utils/src/test/java/com/google/android/accessibility/utils/output/NumbersAsDigitsTest.kt diff --git a/differences.md b/differences.md index c251c7bd8..9e060e3f3 100644 --- a/differences.md +++ b/differences.md @@ -213,7 +213,13 @@ When you turn speech off with its gesture or keyboard shortcut, TalkBack also dr 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. It changes only the text sent for speech; the app's text and +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 @@ -229,7 +235,7 @@ Delimiters were not reliably announced with Vocalizer's own **Process punctuatio 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. +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 diff --git a/talkback/src/main/res/values/strings.xml b/talkback/src/main/res/values/strings.xml index 5b8edb169..f57de8444 100644 --- a/talkback/src/main/res/values/strings.xml +++ b/talkback/src/main/res/values/strings.xml @@ -4496,6 +4496,6 @@ Do not use any markdown in your response." Return to the last focused item when going back to a screen. Speak numbers as digits - - Read consecutive digits individually, including leading zeros + + Read digits individually, including leading zeros, and separate adjacent letters, currency symbols and numeric punctuation. diff --git a/talkback/src/test/java/com/google/android/accessibility/talkback/preference/base/NumbersAsDigitsPreferenceTest.kt b/talkback/src/test/java/com/google/android/accessibility/talkback/preference/base/NumbersAsDigitsPreferenceTest.kt new file mode 100644 index 000000000..4b0097566 --- /dev/null +++ b/talkback/src/test/java/com/google/android/accessibility/talkback/preference/base/NumbersAsDigitsPreferenceTest.kt @@ -0,0 +1,66 @@ +/* + * 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.talkback.preference.base + +import android.content.Context +import android.content.SharedPreferences +import com.google.android.accessibility.talkback.R +import com.google.android.accessibility.utils.SharedPreferencesUtils +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 +import org.xmlpull.v1.XmlPullParser + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [26, 34]) +class NumbersAsDigitsPreferenceTest { + @Test fun disabledByDefaultAndPersistsChanges() { + val context = RuntimeEnvironment.getApplication() + val prefs = context.getSharedPreferences("digits-test", Context.MODE_PRIVATE) + prefs.edit().clear().commit() + assertFalse(read(prefs, context)) + val key = context.getString(R.string.pref_speak_numbers_as_digits_key) + prefs.edit().putBoolean(key, true).commit() + assertTrue(read(prefs, context)) + prefs.edit().putBoolean(key, false).commit() + assertFalse(read(prefs, context)) + } + + @Test fun verbositySwitchUsesPersistentKeyAndDisabledDefault() { + val context = RuntimeEnvironment.getApplication() + context.resources.getXml(R.xml.verbosity_preferences).use { xml -> + 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/utils/src/main/java/com/google/android/accessibility/utils/output/NumbersAsDigits.java b/utils/src/main/java/com/google/android/accessibility/utils/output/NumbersAsDigits.java deleted file mode 100644 index 21e3cf435..000000000 --- a/utils/src/main/java/com/google/android/accessibility/utils/output/NumbersAsDigits.java +++ /dev/null @@ -1,178 +0,0 @@ -/* - * 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; -import androidx.annotation.Nullable; - -/** Separates digits and adjacent delimiters in a speech-only copy, retaining source positions. */ -public final class NumbersAsDigits { - private static volatile boolean enabled; - - public static void setEnabled(boolean value) { - enabled = value; - } - - @Nullable - static Result process(@Nullable CharSequence source) { - return process(source, enabled); - } - - /** Returns null when no change is needed, so disabled speech keeps the original object. */ - @Nullable - static Result process(@Nullable CharSequence source, boolean enabled) { - if (!enabled || source == null || source.length() < 2) { - return null; - } - int length = source.length(); - boolean[] insertBefore = new boolean[length + 1]; - int spaces = 0; - int previous = -1; - for (int i = 0; i < length; ) { - int codePoint = Character.codePointAt(source, i); - if ((Character.isDigit(previous) && isNumericNeighbour(codePoint)) - || (Character.isDigit(codePoint) && isNumericNeighbour(previous))) { - insertBefore[i] = true; - spaces++; - } - previous = codePoint; - i += Character.charCount(codePoint); - } - if (spaces == 0) { - return null; - } - - StringBuilder output = new StringBuilder(length + spaces); - int[] sourceToSpeech = new int[length + 1]; - int[] speechToSource = new int[length + spaces + 1]; - for (int i = 0; i < length; i++) { - if (insertBefore[i]) { - output.append(' '); - speechToSource[output.length()] = i; - } - sourceToSpeech[i] = output.length(); - output.append(source.charAt(i)); - speechToSource[output.length()] = i + 1; - } - sourceToSpeech[length] = output.length(); - CharSequence speech = output.toString(); - if (source instanceof Spanned spanned) { - SpannableString copy = new SpannableString(speech); - for (Object span : spanned.getSpans(0, length, Object.class)) { - int start = spanned.getSpanStart(span); - int end = spanned.getSpanEnd(span); - int mappedStart = sourceToSpeech[start]; - int mappedEnd = sourceToSpeech[end] - (insertBefore[end] ? 1 : 0); - // Number interpretation would override the spaces. Keep explicit text replacements, - // punctuation, language, formatting and digit-by-digit spans. Override number readings. - if (span instanceof TtsSpan ttsSpan && conflictsWithSpacing(ttsSpan) - && mappedEnd - mappedStart > end - start) { - continue; - } - copy.setSpan(span, mappedStart, Math.max(mappedStart, mappedEnd), - spanned.getSpanFlags(span)); - } - speech = copy; - } - return new Result(protect(speech), speechToSource); - } - - /** Tags a private copy so protection follows prepared speech through queues and resume. */ - static CharSequence protect(CharSequence text) { - if (isPreparedText(text)) { - return text; - } - SpannableString copy = new SpannableString(text); - copy.setSpan(new PreparedSpeechSpan(), 0, copy.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - return copy; - } - - static boolean isPreparedText(CharSequence text) { - return text instanceof Spanned spanned - && spanned.getSpans(0, text.length(), PreparedSpeechSpan.class).length != 0; - } - - /** - * Cuts a prepared speech copy without repeating an instruction for the unsliced text. - * Arbitrary replacement arguments cannot be divided using character offsets, so a partial - * instruction falls back to the visible text. Complete instructions and other spans survive. - */ - static CharSequence subSequenceForSpeech(CharSequence text, int start, int end) { - return subSequenceForSpeech(text, start, end, isPreparedText(text)); - } - - static CharSequence subSequenceForSpeech( - CharSequence text, int start, int end, boolean preparedSpeech) { - CharSequence slice = text.subSequence(start, end); - if (!preparedSpeech || !(text instanceof Spanned spanned)) { - return slice; - } - SpannableString copy = null; - for (TtsSpan span : spanned.getSpans(start, end, TtsSpan.class)) { - if (!span.getArgs().isEmpty() - && (spanned.getSpanStart(span) < start || spanned.getSpanEnd(span) > end)) { - if (copy == null) { - copy = new SpannableString(slice); - } - copy.removeSpan(span); - } - } - return copy == null ? slice : copy; - } - - /** Internal metadata only; never attached to accessibility-node text. */ - private static final class PreparedSpeechSpan {} - - /** Character boundaries only: no currency, decimal, sign or identifier recognition. */ - private static boolean isNumericNeighbour(int codePoint) { - return Character.isLetterOrDigit(codePoint) - || Character.getType(codePoint) == Character.CURRENCY_SYMBOL - || codePoint == ',' - || codePoint == '.' - || codePoint == '+' - || codePoint == '-' - || codePoint == '\u2212'; - } - - private static boolean conflictsWithSpacing(TtsSpan span) { - return switch (span.getType()) { - case 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; - default -> false; - }; - } - - /** Immutable mapping for this exact speech request, including a resumed fragment's suffix. */ - static final class Result { - final CharSequence text; - private final int[] speechToSource; - - Result(CharSequence text, int[] speechToSource) { - this.text = text; - this.speechToSource = speechToSource; - } - - int sourceOffset(int speechOffset) { - return speechToSource[Math.max(0, Math.min(speechOffset, speechToSource.length - 1))]; - } - } - - private NumbersAsDigits() {} -} 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/test/java/com/google/android/accessibility/utils/output/FailoverNumbersAsDigitsTest.java b/utils/src/test/java/com/google/android/accessibility/utils/output/FailoverNumbersAsDigitsTest.java deleted file mode 100644 index f830b04cb..000000000 --- a/utils/src/test/java/com/google/android/accessibility/utils/output/FailoverNumbersAsDigitsTest.java +++ /dev/null @@ -1,221 +0,0 @@ -/* - * 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 static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; -import static org.robolectric.Shadows.shadowOf; - -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.ArrayList; -import java.util.List; -import java.util.Locale; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.RuntimeEnvironment; -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 the actual engine requests, including the suspended cache queue. */ -@RunWith(RobolectricTestRunner.class) -@Config(sdk = {26, 34}, shadows = FailoverNumbersAsDigitsTest.RecordingTts.class) -public class FailoverNumbersAsDigitsTest { - private FailoverTextToSpeech failover; - - @Before public void setUp() { - Context context = RuntimeEnvironment.getApplication(); - failover = new FailoverTextToSpeech(context, false); - ReflectionHelpers.setField(failover, "tts", new TextToSpeech(context, status -> {})); - ReflectionHelpers.setField(failover, "lowLatencyAudio", false); - ReflectionHelpers.setField(failover, "speakInPhrases", false); - VoiceProfiles.setActive(null); - NumbersAsDigits.setEnabled(true); - RecordingTts.calls.clear(); - } - - @After public void tearDown() { - failover.shutdown(); - NumbersAsDigits.setEnabled(false); - RecordingTts.calls.clear(); - } - - @Test public void longDigitsArgumentIsNotRepeatedInEveryEngineChunk() { - assertLongArgumentIsNotRepeated(false); - } - - @Test public void longTextArgumentIsNotRepeatedInEveryEngineChunk() { - assertLongArgumentIsNotRepeated(true); - } - - private void assertLongArgumentIsNotRepeated(boolean textArgument) { - String original = "1234567890".repeat(300); - SpannableString source = new SpannableString(original); - TtsSpan instruction = textArgument ? new TtsSpan.TextBuilder(original).build() - : new TtsSpan.DigitsBuilder(original).build(); - LocaleSpan language = new LocaleSpan(Locale.US); - StyleSpan style = new 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); - CharSequence speech = NumbersAsDigits.process(source, true).text; - speakInChunks(speech); - assertTrue(RecordingTts.calls.size() > 1); - StringBuilder joined = new StringBuilder(); - for (CharSequence call : RecordingTts.calls) { - assertTrue(call.length() < TextToSpeech.getMaxSpeechInputLength()); - Spanned spanned = (Spanned) call; - assertEquals(0, spanned.getSpans(0, call.length(), TtsSpan.class).length); - 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 public void completeInstructionsSurviveButAnUnexpandedCrossingSpanDoesNot() { - SpannableString source = new SpannableString("x".repeat(180) + ", " + "1".repeat(2500)); - TtsSpan replacement = new TtsSpan.TextBuilder("alternate wording").build(); - TtsSpan comma = new TtsSpan.TextBuilder("comma").build(); - TtsSpan complete = new 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); - int commas = 0; - int completeReplacements = 0; - for (CharSequence call : RecordingTts.calls) { - Spanned spanned = (Spanned) call; - assertEquals(-1, spanned.getSpanStart(replacement)); - if (spanned.getSpanStart(complete) >= 0) { - completeReplacements++; - assertEquals("fully retained", complete.getArgs().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 public void suspendedExpandedSpeechUsesTheLengthGuardEvenAfterSettingIsTurnedOff() { - FakeSpeakingCache cache = new FakeSpeakingCache(RuntimeEnvironment.getApplication()); - ReflectionHelpers.setField(failover, "speechCacheManager", cache); - CharSequence speech = NumbersAsDigits.process("1".repeat(3000), true).text; - enqueueBehindCache(speech); - assertTrue(RecordingTts.calls.isEmpty()); - NumbersAsDigits.setEnabled(false); - finishCachedSpeech(); - assertTrue(RecordingTts.calls.size() > 1); - StringBuilder joined = new StringBuilder(); - for (CharSequence call : RecordingTts.calls) { - assertTrue(call.length() < TextToSpeech.getMaxSpeechInputLength()); - joined.append(call); - } - assertEquals(speech.toString(), joined.toString()); - } - - @Test public void suspendedUnpreparedSpeechKeepsItsExistingSingleRequestBehaviour() { - FakeSpeakingCache cache = new FakeSpeakingCache(RuntimeEnvironment.getApplication()); - ReflectionHelpers.setField(failover, "speechCacheManager", cache); - NumbersAsDigits.setEnabled(false); - ReflectionHelpers.setField(failover, "speakInPhrases", true); - String original = "word ".repeat(100); - enqueueBehindCache(original); - assertTrue(RecordingTts.calls.isEmpty()); - finishCachedSpeech(); - assertEquals(1, RecordingTts.calls.size()); - assertEquals(original, RecordingTts.calls.get(0).toString()); - } - - private void speakInChunks(CharSequence text) { - ReflectionHelpers.callInstanceMethod(failover, "speakInChunks", - ClassParameter.from(CharSequence.class, text), - ClassParameter.from(int.class, TextToSpeech.QUEUE_ADD), - ClassParameter.from(Bundle.class, new Bundle()), - ClassParameter.from(String.class, "test"), - ClassParameter.from(Locale.class, Locale.US)); - } - - private void enqueueBehindCache(CharSequence text) { - ReflectionHelpers.callInstanceMethod(failover, "speakWithCacheOrTts", - ClassParameter.from(String.class, "test"), - ClassParameter.from(CharSequence.class, text), - ClassParameter.from(int.class, TextToSpeech.QUEUE_ADD), - ClassParameter.from(Locale.class, Locale.US), - ClassParameter.from(Bundle.class, new Bundle())); - } - - private void finishCachedSpeech() { - SpeechCachePlayer.PlaybackStateListener listener = - ReflectionHelpers.getField(failover, "playbackStateListener"); - listener.onStateChanged("cached", SpeechCachePlayer.STATE_COMPLETED); - shadowOf(Looper.getMainLooper()).idle(); - } - - private static final class FakeSpeakingCache extends SpeechCacheManager { - FakeSpeakingCache(Context context) { - super(context, workerLooper()); - } - - private static Looper workerLooper() { - HandlerThread worker = new HandlerThread("test-cache"); - worker.start(); - return worker.getLooper(); - } - - @Override public boolean isSpeaking() { - // Cache completion is posted before playback finally clears its playing flag. - return true; - } - } - - @Implements(TextToSpeech.class) - public static class RecordingTts extends ShadowTextToSpeech { - static final List calls = new ArrayList<>(); - - @Implementation - @Override protected int speak(CharSequence text, int queueMode, Bundle params, String id) { - calls.add(text); - return TextToSpeech.SUCCESS; - } - } -} 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.java b/utils/src/test/java/com/google/android/accessibility/utils/output/NumbersAsDigitsTest.java deleted file mode 100644 index 5a8a21dce..000000000 --- a/utils/src/test/java/com/google/android/accessibility/utils/output/NumbersAsDigitsTest.java +++ /dev/null @@ -1,355 +0,0 @@ -/* - * 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 static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertTrue; - -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.Arrays; -import java.util.List; -import java.util.Locale; -import org.junit.After; -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}) -public class NumbersAsDigitsTest { - @After public void resetPreference() { - NumbersAsDigits.setEnabled(false); - } - - private String spoken(String source) { - NumbersAsDigits.Result result = NumbersAsDigits.process(source, true); - return result == null ? source : result.text.toString(); - } - - @Test public void 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 public void disabledAndUnchangedInputBypassTransformation() { - for (String source : Arrays.asList("", "ordinary text", "1", "1 2", "000123", "$1,500.00", "R7,600.00")) { - assertNull(NumbersAsDigits.process(source, false)); - } - for (String source : Arrays.asList("", "ordinary text", "1", "1 2")) { - assertNull(NumbersAsDigits.process(source, true)); - } - assertNull(NumbersAsDigits.process(null, true)); - } - - @Test public void numericDelimitersAreSeparatedAndWhitespaceIsPreserved() { - assertEquals("- 1 2 , 3 4 . 5 6 / 7 8\n9 0\t1 2", - spoken("-12,34.56 / 78\n90\t12")); - } - - @Test public void 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 public void 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 public void unicodeDigitsAreSeparatedAtCodePointBoundaries() { - assertEquals("١ ٢ ٣", spoken("١٢٣")); - assertEquals("𝟙 𝟚", spoken("𝟙𝟚")); - assertEquals("😀1 2😀", spoken("😀12😀")); - } - - @Test public void positionsMapBackBeforeInsertedSpacesAndAfterTheNumber() { - NumbersAsDigits.Result result = NumbersAsDigits.process("A 12 B", true); - assertEquals("A 1 2 B", result.text.toString()); - int[] expected = {0, 1, 2, 3, 3, 4, 5, 6}; - for (int i = 0; i < expected.length; i++) { - assertEquals(expected[i], result.sourceOffset(i)); - } - assertEquals(0, result.sourceOffset(-1)); - assertEquals(6, result.sourceOffset(100)); - } - - @Test public void languageStyleAndPunctuationSpansAreRemappedWithoutMutatingSource() { - SpannableString original = new SpannableString("12,34"); - LocaleSpan language = new LocaleSpan(Locale.FRENCH); - StyleSpan style = new StyleSpan(Typeface.BOLD); - TtsSpan punctuation = new 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); - Spanned speech = (Spanned) NumbersAsDigits.process(original, true).text; - 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 public void delimiterSpansFromPunctuationProcessingKeepTheirExactCharacterRanges() { - SpannableString original = new SpannableString("$1,500.00"); - LocaleSpan language = new LocaleSpan(Locale.US); - original.setSpan(language, 0, original.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - for (int verbosity : new int[] {SpeechCleanupUtils.ALL, SpeechCleanupUtils.MOST, - SpeechCleanupUtils.SOME}) { - SpannableString source = new SpannableString(original); - // makeSpeakablePunctuation uses the same lookup and single-character TTS spans. - for (int index : new int[] {0, 2, 6}) { - String spokenName = SpeechCleanupUtils.characterToName( - RuntimeEnvironment.getApplication(), source.charAt(index), verbosity); - if (spokenName != null) { - source.setSpan(new TtsSpan.TextBuilder(spokenName).build(), index, index + 1, - Spanned.SPAN_INCLUSIVE_EXCLUSIVE); - } - } - NumbersAsDigits.Result result = NumbersAsDigits.process(source, true); - Spanned speech = (Spanned) result.text; - assertEquals("$ 1 , 5 0 0 . 0 0", speech.toString()); - assertEquals(17, speech.getSpanEnd(language)); - for (TtsSpan punctuation : source.getSpans(0, source.length(), TtsSpan.class)) { - int 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)); - } - int[] expected = {0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9}; - for (int i = 0; i < expected.length; i++) { - assertEquals(expected[i], result.sourceOffset(i)); - } - assertEquals("$1,500.00", source.toString()); - assertEquals(9, source.getSpanEnd(language)); - } - } - - @Test public void delimiterResumeKeepsOriginalCursorAndPreparesRemainingSuffix() { - NumbersAsDigits.setEnabled(true); - FeedbackFragment fragment = new FeedbackFragment("$1,500.00", null); - FeedbackFragmentsIterator iterator = - new FeedbackFragmentsIterator(Arrays.asList(fragment).iterator(), "test"); - iterator.next(); - assertEquals("$ 1 , 5 0 0 . 0 0", iterator.prepareSpeechText(fragment.getText()).toString()); - iterator.onFragmentRangeStarted("test", iterator.sourceOffset(4), iterator.sourceOffset(5)); - iterator.next(); - assertEquals(",500.00", fragment.getText().toString()); - assertEquals(2, iterator.getFeedbackItemOffset()); - assertEquals(", 5 0 0 . 0 0", iterator.prepareSpeechText(fragment.getText()).toString()); - } - - @Test public void conflictingNumberSpanIsOverriddenOnlyInTheSpeechCopy() { - SpannableString original = new SpannableString("000123"); - TtsSpan number = new TtsSpan.CardinalBuilder("123").build(); - original.setSpan(number, 0, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - Spanned speech = (Spanned) NumbersAsDigits.process(original, true).text; - 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 public void existingDigitSpanUsedByColorAnnouncementsRemains() { - SpannableString original = new SpannableString("00FF"); - TtsSpan digits = new TtsSpan.DigitsBuilder("00FF").build(); - original.setSpan(digits, 0, 4, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - Spanned speech = (Spanned) NumbersAsDigits.process(original, true).text; - assertEquals("0 0 FF", speech.toString()); - assertEquals(0, speech.getSpanStart(digits)); - assertEquals(6, speech.getSpanEnd(digits)); - assertEquals("00FF", digits.getArgs().getString(TtsSpan.ARG_DIGITS)); - } - - @Test public void singleDigitAndExplicitReplacementSpansRemain() { - SpannableString original = new SpannableString("123"); - TtsSpan oneDigit = new TtsSpan.CardinalBuilder("2").build(); - TtsSpan replacement = new TtsSpan.TextBuilder("existing speech replacement").build(); - original.setSpan(oneDigit, 1, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - original.setSpan(replacement, 0, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - Spanned speech = (Spanned) NumbersAsDigits.process(original, true).text; - assertEquals(2, speech.getSpanStart(oneDigit)); - assertEquals(3, speech.getSpanEnd(oneDigit)); - assertEquals(0, speech.getSpanStart(replacement)); - assertEquals(5, speech.getSpanEnd(replacement)); - } - - @Test public void resumeUsesOriginalDigitPositionAndRebuildsSuffixMapping() { - NumbersAsDigits.setEnabled(true); - FeedbackFragment fragment = new FeedbackFragment("Code 00123", null); - FeedbackFragmentsIterator iterator = - new FeedbackFragmentsIterator(Arrays.asList(fragment).iterator(), "test"); - assertSame(fragment, iterator.next()); - assertEquals("Code 0 0 1 2 3", iterator.prepareSpeechText(fragment.getText()).toString()); - // The engine begins the second zero at speech offset 7, original offset 6. - int start = iterator.sourceOffset(7); - assertEquals(6, start); - iterator.onFragmentRangeStarted("test", start, iterator.sourceOffset(8)); - assertSame(fragment, iterator.next()); - assertEquals("0123", fragment.getText().toString()); - assertEquals(6, iterator.getFeedbackItemOffset()); - assertTrue(iterator.nextIsContinuation()); - assertEquals("0 1 2 3", iterator.prepareSpeechText(fragment.getText()).toString()); - assertEquals(2, iterator.sourceOffset(4)); - } - - @Test public void preferenceToggleDoesNotChangeMappingOfAlreadySubmittedSpeech() { - NumbersAsDigits.setEnabled(true); - FeedbackFragment fragment = new FeedbackFragment("123", null); - FeedbackFragmentsIterator iterator = - new FeedbackFragmentsIterator(Arrays.asList(fragment).iterator(), "test"); - iterator.next(); - iterator.prepareSpeechText(fragment.getText()); - FeedbackFragmentsIterator saved = iterator.deepCopy(); - NumbersAsDigits.setEnabled(false); - assertEquals(2, iterator.sourceOffset(4)); - assertSame(fragment.getText(), iterator.prepareSpeechText(fragment.getText())); - assertEquals(2, iterator.sourceOffset(2)); - assertEquals(2, saved.sourceOffset(4)); - } - - @Test public void resumedLastDigitDoesNotReplayTheOriginalDigitsArgument() { - assertResumedArgumentIsNotReplayed(false); - } - - @Test public void resumedSuffixDoesNotReplayTheOriginalTextArgument() { - assertResumedArgumentIsNotReplayed(true); - } - - private void assertResumedArgumentIsNotReplayed(boolean textArgument) { - NumbersAsDigits.setEnabled(true); - SpannableString original = new SpannableString("123456"); - TtsSpan instruction = textArgument ? new TtsSpan.TextBuilder("123456").build() - : new TtsSpan.DigitsBuilder("123456").build(); - LocaleSpan language = new LocaleSpan(Locale.US); - original.setSpan(instruction, 0, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - original.setSpan(language, 0, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - FeedbackFragment fragment = new FeedbackFragment(original, null); - FeedbackFragmentsIterator iterator = - new FeedbackFragmentsIterator(Arrays.asList(fragment).iterator(), "test"); - iterator.next(); - assertEquals("1 2 3 4 5 6", iterator.prepareSpeechText(fragment.getText()).toString()); - // Preparing speech must not attach private metadata to stored source/history text. - assertEquals(2, ((Spanned) fragment.getText()).getSpans(0, 6, Object.class).length); - iterator.onFragmentRangeStarted("test", iterator.sourceOffset(10), iterator.sourceOffset(11)); - iterator.next(); - assertEquals(5, iterator.getFeedbackItemOffset()); - assertEquals("6", fragment.getText().toString()); - Spanned speech = (Spanned) iterator.prepareSpeechText(fragment.getText()); - assertEquals("6", speech.toString()); - assertEquals(0, speech.getSpans(0, 1, TtsSpan.class).length); - 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 public void resumeAtAReplacementBoundaryRetainsTheCompleteInstruction() { - NumbersAsDigits.setEnabled(true); - SpannableString source = new SpannableString("12 ABC"); - TtsSpan complete = new TtsSpan.TextBuilder("fully retained").build(); - source.setSpan(complete, 3, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - FeedbackFragment fragment = new FeedbackFragment(source, null); - FeedbackFragmentsIterator iterator = - new FeedbackFragmentsIterator(Arrays.asList(fragment).iterator(), "test"); - iterator.next(); - assertEquals("1 2 ABC", iterator.prepareSpeechText(fragment.getText()).toString()); - iterator.onFragmentRangeStarted("test", iterator.sourceOffset(4), iterator.sourceOffset(7)); - iterator.next(); - Spanned remaining = (Spanned) iterator.prepareSpeechText(fragment.getText()); - assertEquals("ABC", remaining.toString()); - assertEquals(3, iterator.getFeedbackItemOffset()); - assertEquals(0, remaining.getSpanStart(complete)); - assertEquals(3, remaining.getSpanEnd(complete)); - assertEquals("fully retained", complete.getArgs().getString(TtsSpan.ARG_TEXT)); - } - - @Test public void aPlainStringFragmentCanResumeAfterDigitPreparation() { - NumbersAsDigits.setEnabled(true); - FeedbackFragment fragment = new FeedbackFragment("123456", null); - fragment.setText("123456"); - FeedbackFragmentsIterator iterator = - new FeedbackFragmentsIterator(Arrays.asList(fragment).iterator(), "test"); - iterator.next(); - iterator.prepareSpeechText(fragment.getText()); - iterator.onFragmentRangeStarted("test", iterator.sourceOffset(10), iterator.sourceOffset(11)); - iterator.next(); - assertEquals("6", iterator.prepareSpeechText(fragment.getText()).toString()); - assertEquals(5, iterator.getFeedbackItemOffset()); - } - - @Test public void disabledResumeKeepsTheExistingSpanAndTextBehaviour() { - NumbersAsDigits.setEnabled(false); - SpannableString source = new SpannableString("123456"); - TtsSpan instruction = new TtsSpan.TextBuilder("existing replacement").build(); - source.setSpan(instruction, 0, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - FeedbackFragment fragment = new FeedbackFragment(source, null); - FeedbackFragmentsIterator iterator = - new FeedbackFragmentsIterator(Arrays.asList(fragment).iterator(), "test"); - iterator.next(); - assertSame(fragment.getText(), iterator.prepareSpeechText(fragment.getText())); - iterator.onFragmentRangeStarted("test", 5, 6); - iterator.next(); - Spanned suffix = (Spanned) iterator.prepareSpeechText(fragment.getText()); - assertEquals("6", suffix.toString()); - assertEquals(0, suffix.getSpanStart(instruction)); - assertEquals(1, suffix.getSpanEnd(instruction)); - assertEquals("existing replacement", instruction.getArgs().getString(TtsSpan.ARG_TEXT)); - } - - @Test public void expandedTextCanBeChunkedWithinTheEngineLimit() { - String original = "1".repeat(TextToSpeech.getMaxSpeechInputLength() - 1); - CharSequence speech = NumbersAsDigits.process(original, true).text; - assertTrue(speech.length() > TextToSpeech.getMaxSpeechInputLength()); - List starts = SpeechChunker.chunkStarts(speech, Locale.US); - assertTrue(starts.size() > 1); - StringBuilder joined = new StringBuilder(); - for (int i = 0; i < starts.size(); i++) { - int end = i + 1 < starts.size() ? starts.get(i + 1) : speech.length(); - assertTrue(end - starts.get(i) < TextToSpeech.getMaxSpeechInputLength()); - joined.append(speech.subSequence(starts.get(i), end)); - } - assertEquals(speech.toString(), joined.toString()); - } -} 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") +}