diff --git a/differences.md b/differences.md index 38f8ae05c..7ed866079 100644 --- a/differences.md +++ b/differences.md @@ -154,6 +154,12 @@ When text is marked as another language, TalkBack switches the voice to that lan When you turn a change off, Backtalk no longer splits the text or pauses around it. Text marked as the voice's own language and country, such as US English for a US English voice, isn't split either, whatever the settings, since the voice doesn't change. Backtalk compares languages by their codes only, so Serbian in Latin and in Cyrillic script counts as two dialects, and Norwegian marked as `no` and as `nb` counts as two languages. +### Speak numbers as digits + +**Verbosity** > **Speak numbers as digits** asks a compatible speech engine to read consecutive digits separately, including leading zeros. It is off by default. It applies to numbers from 2 to 32 digits, including six-digit codes and twenty-digit electricity tokens. Runs of 33 or more digits and instructions supplied by an app are left alone. + +This uses Android's pronunciation hints without changing the text. Some engines, including the tested Vocalizer Tessa voice, may ignore the hint and continue reading numbers normally. There is no automatic fallback to another reading method. + ### How emoji are spoken Some speech engines read emoji badly, use names of their own, or don't read them at all. **Verbosity** > **Emoji** chooses how they're spoken: diff --git a/lint.xml b/lint.xml index bf21e2f7b..2b9b06d73 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 be93dd740..24f10c408 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 @@ -309,6 +309,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; @@ -3662,6 +3663,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 708a34108..250fc6650 100644 --- a/talkback/src/main/res/values/donottranslate.xml +++ b/talkback/src/main/res/values/donottranslate.xml @@ -2173,4 +2173,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 8780a98bc..7dd7a86d1 100644 --- a/talkback/src/main/res/values/strings.xml +++ b/talkback/src/main/res/values/strings.xml @@ -4509,4 +4509,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 + + Ask a compatible speech engine to read numbers of 2 to 32 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" /> + + + + var found = 0 + while (xml.next() != XmlPullParser.END_DOCUMENT) { + if (xml.eventType != XmlPullParser.START_TAG) continue + val ns = "http://schemas.android.com/apk/res/android" + if (xml.getAttributeResourceValue(ns, "key", 0) != R.string.pref_speak_numbers_as_digits_key) continue + found++ + assertEquals(R.bool.pref_speak_numbers_as_digits_default, + xml.getAttributeResourceValue(ns, "defaultValue", 0)) + assertTrue(xml.name.endsWith("AccessibilitySuiteSwitchPreference")) + } + assertEquals(1, found) + } + } + + private fun read(prefs: SharedPreferences, context: Context) = + SharedPreferencesUtils.getBooleanPref(prefs, context.resources, + R.string.pref_speak_numbers_as_digits_key, R.bool.pref_speak_numbers_as_digits_default) +} diff --git a/talkback/src/wear/res/xml-watch/verbosity_preferences.xml b/talkback/src/wear/res/xml-watch/verbosity_preferences.xml index 3e64d7f7c..24130c550 100644 --- a/talkback/src/wear/res/xml-watch/verbosity_preferences.xml +++ b/talkback/src/wear/res/xml-watch/verbosity_preferences.xml @@ -174,6 +174,14 @@ android:title="@string/title_pref_speak_element_ids" app:splitSummary="true" /> + + + start && + source.getSpanStart(it) < source.getSpanEnd(it) + } + + // A standard TYPE_DIGITS span on the wire. The subtype lets us adjust our own sliced + // instructions without changing explicit application-provided TtsSpan arguments. + private class DigitsSpan(digits: String) : + TtsSpan(TtsSpan.TYPE_DIGITS, TtsSpan.DigitsBuilder().setDigits(digits).build().args) +} 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..c631b1d6a 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 @@ -1243,6 +1243,7 @@ public void speak( text = replaceSpanByContentDescription(text); text = replaceBrailleSymbolByDescription(mContext, text); text = EmojiSpeech.process(mContext, text); + text = NumbersAsDigits.apply(text); final FeedbackItem pendingItem = FeedbackProcessingUtils.generateFeedbackItemFromInput( mContext, 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..0286ab01c --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/output/FailoverNumbersAsDigitsTest.kt @@ -0,0 +1,246 @@ +/* + * 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.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.TtsSpan +import com.google.android.accessibility.utils.output.NumbersAsDigitsTest.Companion.assertDigits +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 + +/** Captures the actual CharSequence supplied to Android's TTS API. */ +@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 cleanup() { + failover.shutdown() + NumbersAsDigits.setEnabled(false) + VoiceProfiles.setActive(null) + RecordingTts.calls.clear() + } + + @Test fun finalSpeakReceivesSpanAndUnchangedText() { + val source = "PIN 283482, token 00123456789012345678" + submit(source, "test") + assertEquals(1, RecordingTts.calls.size) + val text = RecordingTts.calls.single() + assertEquals(source, text.toString()) + assertDigits(text, 4, 10) + assertDigits(text, 18, 38) + } + + @Test fun twentyDigitTokenRetainsItsFullSpanAtSubmission() { + submit("00123456789012345678", "twenty") + assertDigits(RecordingTts.calls.single(), 0, 20) + } + + @Test fun thirtyTwoDigitsRetainTheirFullSpanAtSubmission() { + submit("1".repeat(32), "thirty-two") + assertDigits(RecordingTts.calls.single(), 0, 32) + } + + @Test fun thirtyThreeDigitsReachTheEngineEntirelyUnannotated() { + val source = "1".repeat(33) + submit(source, "thirty-three") + assertSame(source, RecordingTts.calls.single()) + } + + @Test fun feedbackProcessingPreservesOriginalTextAndSpanUntilSubmission() { + val source = "PIN 283482" + val item = feedback(source) + assertEquals(1, item.fragments.size) + val fragment = item.fragments.single().text + assertEquals(source, fragment.toString()) + submitPrepared(fragment, "feedback") + assertEquals(source, RecordingTts.calls.single().toString()) + assertDigits(RecordingTts.calls.single(), 4, 10) + } + + @Test fun feedbackSplittingCannotAnnotateTheShortTailOfAnOverLimitRun() { + val source = "1".repeat(TextToSpeech.getMaxSpeechInputLength() + 20) + val item = feedback(source) + assertTrue(item.fragments.size > 1) + for ((index, fragment) in item.fragments.withIndex()) { + submitPrepared(fragment.text, "fragment-$index") + } + assertEquals(source, RecordingTts.calls.joinToString("")) + RecordingTts.calls.forEach { assertNoDigitsSpan(it) } + } + + @Test fun explicitApplicationInstructionSurvivesAtTheSpeakBoundary() { + val source = SpannableString("283482") + val instruction = TtsSpan.CardinalBuilder(283482).build() + source.setSpan(instruction, 0, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + submit(source, "explicit") + assertSame(source, RecordingTts.calls.single()) + assertSame(instruction, (RecordingTts.calls.single() as Spanned) + .getSpans(0, 6, TtsSpan::class.java).single()) + } + + @Test fun liveDisableRestoresOrdinarySubmissionWithoutOffsetMapping() { + submit("283482", "on") + NumbersAsDigits.setEnabled(false) + val plain = "283482" + submit(plain, "off") + assertDigits(RecordingTts.calls[0], 0, 6) + assertSame(plain, RecordingTts.calls[1]) + } + + @Test fun chunkArgumentsDescribeOnlyTheSliceOfAnEligibleOriginalRun() { + ReflectionHelpers.setField(failover, "speakInPhrases", true) + val source = "X".repeat(140) + "1".repeat(32) + "Y".repeat(30) + submit(source, "chunked") + assertTrue(RecordingTts.calls.size > 1) + for (call in RecordingTts.calls) { + val start = call.indexOfFirst { it == '1' } + if (start >= 0) assertDigits(call, start, call.indexOfLast { it == '1' } + 1) + } + assertEquals(source, RecordingTts.calls.joinToString("")) + } + + @Test fun phraseSplittingCannotMakeAThirtyThreeDigitRunEligible() { + ReflectionHelpers.setField(failover, "speakInPhrases", true) + val source = "X".repeat(140) + "1".repeat(33) + "Y".repeat(30) + submit(source, "over-limit-chunks") + assertTrue(RecordingTts.calls.size > 1) + assertEquals(source, RecordingTts.calls.joinToString("")) + RecordingTts.calls.forEach { assertNoDigitsSpan(it) } + } + + @Test fun pausedSuffixUsesOnlyItsOwnDigitsWithoutReplayingTheOriginalNumber() { + val prepared = NumbersAsDigits.apply("283482")!! + submitPrepared(prepared.subSequence(2, 6), "resumed") + assertEquals("3482", RecordingTts.calls.single().toString()) + assertDigits(RecordingTts.calls.single(), 0, 4) + assertDigits(prepared, 0, 6) + } + + @Test fun numericCacheLookupHasSpansAndUnrelatedTextKeepsItsOriginalObject() { + val cache = installCache() + submit("283482", "numeric") + assertDigits(cache.checked, 0, 6) + assertDigits(RecordingTts.calls.single(), 0, 6) + val ordinary = "ordinary text" + submit(ordinary, "plain") + assertSame(ordinary, cache.checked) + NumbersAsDigits.setEnabled(false) + val number = "283482" + submit(number, "disabled") + assertSame(number, cache.checked) + } + + @Test fun speechSuspendedBehindCacheStillReachesTheEngineWithDigitsSpans() { + val cache = installCache() + cache.speaking = true + submit("283482", "suspended") + assertTrue(RecordingTts.calls.isEmpty()) + cache.speaking = false + ReflectionHelpers.callInstanceMethod(failover, "speakSuspendQueue") + shadowOf(Looper.getMainLooper()).idle() + assertDigits(RecordingTts.calls.single(), 0, 6) + } + + private fun feedback(source: CharSequence): FeedbackItem = + FeedbackProcessingUtils.generateFeedbackItemFromInput( + RuntimeEnvironment.getApplication(), NumbersAsDigits.apply(SpannableString(source)), false, + SpeechControllerImpl.InlineFormattingHistory(null), 0, 0, true, + null, null, 0, 0, null, null, null, + ) + + private fun assertNoDigitsSpan(text: CharSequence) { + if (text is Spanned) assertEquals(0, text.getSpans(0, text.length, TtsSpan::class.java).size) + } + + private fun installCache(): RecordingCache { + val worker = HandlerThread("digits-cache-test").apply { start() } + return RecordingCache(RuntimeEnvironment.getApplication(), worker.looper).also { + ReflectionHelpers.setField(failover, "speechCacheManager", it) + } + } + + private class RecordingCache(context: Context, looper: Looper) : SpeechCacheManager(context, looper) { + var speaking = false + var checked: CharSequence? = null + override fun isSpeaking() = speaking + override fun speakWithSpeechCachePlayer( + id: String?, queue: Int, text: CharSequence, params: Bundle, + callback: SpeechCachePlayer.PlaybackStateListener, locale: Locale?, + ): Boolean { + checked = text + return false + } + } + + private fun submit(text: CharSequence, id: String) { + submitPrepared(NumbersAsDigits.apply(text)!!, id) + } + + private fun submitPrepared(text: CharSequence, id: String) { + val result = ReflectionHelpers.callInstanceMethod(failover, "speakWithCacheOrTts", + ClassParameter.from(String::class.java, id), + 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()), + ) + assertEquals(TextToSpeech.SUCCESS, result) + } + + @Implements(TextToSpeech::class) + class RecordingTts : ShadowTextToSpeech() { + @Implementation + override fun speak(text: CharSequence, queueMode: Int, params: Bundle?, id: String?): Int { + calls.add(text) + return TextToSpeech.SUCCESS + } + companion object { + val calls = mutableListOf() + } + } +} diff --git a/utils/src/test/java/com/google/android/accessibility/utils/output/NumbersAsDigitsTest.kt b/utils/src/test/java/com/google/android/accessibility/utils/output/NumbersAsDigitsTest.kt new file mode 100644 index 000000000..529398ca5 --- /dev/null +++ b/utils/src/test/java/com/google/android/accessibility/utils/output/NumbersAsDigitsTest.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.graphics.Typeface +import android.os.Parcel +import android.speech.tts.TextToSpeech +import android.text.SpannableString +import android.text.Spanned +import android.text.TextUtils +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.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [26, 34]) +class NumbersAsDigitsTest { + @Before fun enable() { NumbersAsDigits.setEnabled(true) } + @After fun reset() { NumbersAsDigits.setEnabled(false) } + + @Test fun disabledKeepsOriginalObjectAndSpans() { + val source = SpannableString("283482") + NumbersAsDigits.setEnabled(false) + assertSame(source, NumbersAsDigits.apply(source)) + } + + @Test fun sixDigitCodeKeepsTextAndLength() { + val source = "283482" + val speech = NumbersAsDigits.apply(source) + assertEquals(source, speech.toString()) + assertEquals(source.length, speech!!.length) + assertDigits(speech, 0, source.length) + } + + @Test fun twentyDigitsAreAnnotatedIncludingLeadingZeros() { + val source = "00123456789012345678" + val speech = NumbersAsDigits.apply(source) + assertEquals(source, speech.toString()) + assertEquals(20, speech!!.length) + assertDigits(speech, 0, 20) + } + + @Test fun thirtyTwoDigitsAreAnnotatedInFull() { + val source = "1".repeat(32) + assertDigits(NumbersAsDigits.apply(source), 0, 32) + } + + @Test fun thirtyThreeDigitsAreEntirelyUnannotated() { + val source = "1".repeat(33) + assertSame(source, NumbersAsDigits.apply(source)) + val mixed = NumbersAsDigits.apply("283482 $source 654321") as Spanned + assertDigits(mixed, 0, 6) + assertEquals(0, mixed.getSpans(7, 40, TtsSpan::class.java).size) + assertDigits(mixed, 41, 47) + } + + @Test fun overLimitRunCannotBecomeEligibleAfterSlicing() { + val source = "1".repeat(33) + val prepared = NumbersAsDigits.apply(source)!! + assertSame(source, prepared) + val suffix = prepared.subSequence(1, 33) + assertSame(suffix, NumbersAsDigits.forSpeech(suffix)) + assertFalse(suffix is Spanned) + } + + @Test fun excessiveRunsRemainUnannotatedWithoutHidingLaterEligibleNumbers() { + val excessive = "1".repeat(TextToSpeech.getMaxSpeechInputLength()) + assertSame(excessive, NumbersAsDigits.apply(excessive)) + val mixed = NumbersAsDigits.apply("$excessive 283482")!! + assertDigits(mixed, excessive.length + 1, mixed.length) + } + + @Test fun runsHaveExactIndependentBoundariesIncludingAdjacentLetters() { + val speech = NumbersAsDigits.apply("OTP 001234, token A12345678901234567890Z, digit 7") as Spanned + assertEquals(2, speech.getSpans(0, speech.length, TtsSpan::class.java).size) + assertDigits(speech, 4, 10) + assertDigits(speech, 19, 39) + } + + @Test fun compatibleSpansAndInputArePreserved() { + val source = SpannableString("PIN 283482") + val style = StyleSpan(Typeface.BOLD) + val locale = LocaleSpan(Locale.UK) + source.setSpan(style, 0, 10, Spanned.SPAN_INCLUSIVE_INCLUSIVE) + source.setSpan(locale, 4, 10, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + val speech = NumbersAsDigits.apply(source) as Spanned + assertEquals(0, speech.getSpanStart(style)) + assertEquals(10, speech.getSpanEnd(style)) + assertEquals(Spanned.SPAN_INCLUSIVE_INCLUSIVE, speech.getSpanFlags(style)) + assertEquals(4, speech.getSpanStart(locale)) + assertEquals(10, speech.getSpanEnd(locale)) + assertEquals(0, source.getSpans(0, 10, TtsSpan::class.java).size) + assertDigits(speech, 4, 10) + } + + @Test fun anyOverlappingExplicitPronunciationWinsWithoutDuplication() { + for (instruction in listOf( + TtsSpan.CardinalBuilder(283482).build(), TtsSpan.DigitsBuilder("283482").build(), + TtsSpan.TextBuilder("application pronunciation").build(), + )) { + val source = SpannableString("283482 654321") + source.setSpan(instruction, 1, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + val speech = NumbersAsDigits.apply(source) as Spanned + assertEquals(2, speech.getSpans(0, speech.length, TtsSpan::class.java).size) + assertEquals(1, speech.getSpanStart(instruction)) + assertEquals(5, speech.getSpanEnd(instruction)) + assertDigits(speech, 7, 13) + } + } + + @Test fun adjacentExplicitInstructionDoesNotBlockTheNextRun() { + val source = SpannableString("x283482") + source.setSpan(TtsSpan.TextBuilder("ex").build(), 0, 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + assertDigits(NumbersAsDigits.apply(source), 1, 7) + } + + @Test fun existingFullRunInstructionKeepsOriginalObject() { + val source = SpannableString("283482") + source.setSpan(TtsSpan.CardinalBuilder(283482).build(), 0, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + assertSame(source, NumbersAsDigits.apply(source)) + assertSame(source, NumbersAsDigits.forSpeech(source)) + } + + @Test fun nonNumericAndIsolatedDigitsNeedNoCopy() { + for (source in listOf("", "ordinary text", "1", "a1b", "1 2 3", "-1.2")) { + assertSame(source, NumbersAsDigits.apply(source)) + } + assertNull(NumbersAsDigits.apply(null)) + assertNull(NumbersAsDigits.forSpeech(null)) + } + + @Test fun unicodeDecimalDigitsKeepUtf16BoundariesAndOriginalCharacters() { + val source = "😀 ١٢ 𝟙𝟚" + val speech = NumbersAsDigits.apply(source) + assertEquals(source, speech.toString()) + assertDigits(speech, 3, 5) + assertDigits(speech, 6, 10) + assertDigits(NumbersAsDigits.apply("𝟙".repeat(32)), 0, 64) + val excessive = "𝟙".repeat(33) + assertSame(excessive, NumbersAsDigits.apply(excessive)) + } + + @Test fun repeatedProcessingDoesNotAddDuplicateSpans() { + val speech = NumbersAsDigits.apply("283482") + assertSame(speech, NumbersAsDigits.apply(speech)) + assertSame(speech, NumbersAsDigits.forSpeech(speech)) + assertDigits(speech, 0, 6) + } + + @Test fun slicingAdjustsOnlyOurOwnArgumentsAndPreservesThePreparedInput() { + val prepared = NumbersAsDigits.apply("283482")!! + val suffix = NumbersAsDigits.forSpeech(prepared.subSequence(2, 6)) + assertEquals("3482", suffix.toString()) + assertDigits(suffix, 0, 4) + assertDigits(prepared, 0, 6) + val explicit = SpannableString("283482") + val instruction = TtsSpan.TextBuilder("explicit wording").build() + explicit.setSpan(instruction, 0, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + val sliced = explicit.subSequence(2, 6) + assertSame(sliced, NumbersAsDigits.forSpeech(sliced)) + assertEquals("explicit wording", instruction.args.getString(TtsSpan.ARG_TEXT)) + } + + @Test fun generatedSpanParcelsAsAnOrdinaryAndroidDigitsSpan() { + val speech = NumbersAsDigits.forSpeech(NumbersAsDigits.apply("283482"))!! + val parcel = Parcel.obtain() + try { + TextUtils.writeToParcel(speech, parcel, 0) + parcel.setDataPosition(0) + assertDigits(TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(parcel), 0, 6) + } finally { + parcel.recycle() + } + } + + companion object { + fun assertDigits(text: CharSequence?, start: Int, end: Int) { + assertTrue(text is Spanned) + val spanned = text as Spanned + val spans = spanned.getSpans(start, end, TtsSpan::class.java) + assertEquals(1, spans.size) + assertEquals(TtsSpan.TYPE_DIGITS, spans[0].type) + assertEquals(start, spanned.getSpanStart(spans[0])) + assertEquals(end, spanned.getSpanEnd(spans[0])) + assertEquals(text.subSequence(start, end).toString(), spans[0].args.getString(TtsSpan.ARG_DIGITS)) + } + } +}