Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions differences.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,39 @@ With **Controls** > **Touch and activation** > **Stop speech when touching the s

When you turn speech off with its gesture or keyboard shortcut, TalkBack also dropped the sounds and vibrations that go with each item, so moving focus or activating an item gave no feedback at all. Backtalk still plays them, so you can tell that focus moved while speech is off.

### Speak numbers as digits

In **Verbosity**, **Speak numbers as digits** reads consecutive digits separately,
including leading zeros. For example, `000123` is spoken as `0 0 0 1 2 3`.
It is off by default. Android's native digits pronunciation hints were investigated
first: Google's engine honoured them in a standalone test, but Vocalizer Tessa did
not reliably produce the requested reading. Explicit spacing was chosen after
Oppo tests with Google TTS and Tessa produced the intended individual-digit output.
Neither approach guarantees identical behaviour across engines or voices.

It changes only the text sent for speech; the app's text and
accessibility navigation stay unchanged. Existing spaces and punctuation characters are kept.
Commas, periods, plus and minus signs, currency symbols and letters immediately
next to a digit are separated too: `$1,500.00` is sent for speech as
`$ 1 , 5 0 0 . 0 0`, and `R7` as `R 7`. This applies to letter/digit boundaries
in other text too, such as `A12B` becoming `A 1 2 B`. Other punctuation is left
unchanged. Punctuation is still spoken according to the existing punctuation
setting and speech engine.

On an Oppo CPH2197 running Android 13 with Vocalizer Tessa, turning this option
off kept the usual number pronunciation, and turning it on read consecutive
digits individually.
Delimiters were not reliably announced with Vocalizer's own **Process punctuation**
setting off. Turning on **Process punctuation** and selecting **Most** allowed
commas, periods and other punctuation to be announced. Tessa called the minus
character "dash". These are findings from that synthesizer and device; other
speech engines may pronounce the digits and punctuation differently, including how they say zero.

When new speech is generated for digit-spaced text, app-provided spoken wording
that crosses a speech chunk or resume boundary falls back to the visible text for
that portion. This avoids repeating the complete replacement. Complete spoken
replacements and other text formatting are preserved.

## Notifications

### Samsung watch notification content
Expand Down
2 changes: 2 additions & 0 deletions lint.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
Keep MissingTranslation enabled globally and suppress it only for these exact resources.
-->
<issue id="MissingTranslation">
<ignore regexp="title_pref_speak_numbers_as_digits" />
<ignore regexp="summary_pref_speak_numbers_as_digits" />
<ignore regexp="voice_commands_help_call" />
<ignore regexp="voice_commands_help_call_description" />
<ignore regexp="voice_commands_help_call_control" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions talkback/src/main/res/values/donottranslate.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2148,4 +2148,6 @@
<item type="id" name="voice_profile_menu" />
<string name="pref_restore_focus_on_back_key">pref_restore_focus_on_back</string>
<string name="pref_scroll_to_restore_previous_focus_key">pref_scroll_to_restore_previous_focus</string>
<string name="pref_speak_numbers_as_digits_key" translatable="false">pref_speak_numbers_as_digits</string>
<bool name="pref_speak_numbers_as_digits_default">false</bool>
</resources>
4 changes: 4 additions & 0 deletions talkback/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4494,4 +4494,8 @@ Do not use any markdown in your response."</string>
<string name="title_pref_restore_focus_on_back">Restore focus when going back</string>
<!-- Summary of the phone Advanced Settings switch. Back means returning to the previous app screen or browser page. [CHAR LIMIT=NONE] -->
<string name="summary_pref_restore_focus_on_back">Return to the last focused item when going back to a screen.</string>
<!-- Switch in Verbosity settings. Reads adjacent digits separately instead of as a whole number. [CHAR LIMIT=60] -->
<string name="title_pref_speak_numbers_as_digits">Speak numbers as digits</string>
<!-- Summary of the Verbosity switch: read digits separately and space their adjacent letters and numeric delimiters. [CHAR LIMIT=150] -->
<string name="summary_pref_speak_numbers_as_digits">Read digits individually, including leading zeros, and separate adjacent letters, currency symbols and numeric punctuation.</string>
</resources>
7 changes: 7 additions & 0 deletions talkback/src/main/res/xml/verbosity_preferences.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
<!-- Independent of verbosity presets, like punctuation. -->
<com.google.android.accessibility.material.preference.AccessibilitySuiteSwitchPreference
android:defaultValue="@bool/pref_speak_numbers_as_digits_default"
android:key="@string/pref_speak_numbers_as_digits_key"
android:title="@string/title_pref_speak_numbers_as_digits"
android:summary="@string/summary_pref_speak_numbers_as_digits" />

<!-- Punctuation - Non-preset detailed settings -->
<com.google.android.accessibility.material.preference.AccessibilitySuiteSwitchPreference
android:defaultValue="@bool/pref_punctuation_default"
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
8 changes: 8 additions & 0 deletions talkback/src/wear/res/xml-watch/verbosity_preferences.xml
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,14 @@
android:title="@string/title_pref_speak_element_ids"
app:splitSummary="true" />

<!-- Independent of verbosity presets, like punctuation. -->
<com.google.android.accessibility.material.preference.AccessibilitySuiteSwitchPreference
android:defaultValue="@bool/pref_speak_numbers_as_digits_default"
android:key="@string/pref_speak_numbers_as_digits_key"
android:title="@string/title_pref_speak_numbers_as_digits"
android:summary="@string/summary_pref_speak_numbers_as_digits"
app:splitSummary="true" />

<!-- Punctuation - Non-preset detailed settings -->
<com.google.android.accessibility.material.preference.AccessibilitySuiteSwitchPreference
android:defaultValue="@bool/pref_punctuation_default"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1379,11 +1379,21 @@ private void speakSuspendQueue() {
if (isReady()) {
attemptSetLanguage(speakRequest.locale);
// The queued request's own id: the completing id names the cached speech.
ttsSpeak(
speakRequest.text,
speakRequest.queueMode,
speakRequest.bundle,
speakRequest.utteranceId());
if (NumbersAsDigits.isPreparedText(speakRequest.text)) {
// Prepared speech needs the same length/span handling after cache playback.
speakInChunks(
speakRequest.text,
speakRequest.queueMode,
speakRequest.bundle,
speakRequest.utteranceId(),
speakRequest.locale);
} else {
ttsSpeak(
speakRequest.text,
speakRequest.queueMode,
speakRequest.bundle,
speakRequest.utteranceId());
}
}
});
}
Expand Down Expand Up @@ -1492,8 +1502,11 @@ public void reportEstimatedProgress() {
*/
private int speakInChunks(
CharSequence text, int queueMode, Bundle bundle, String utteranceId, Locale locale) {
// A speech-only expansion can exceed the limit checked before feedback processing.
// Even with phrase splitting off, never submit an oversized request to the engine.
List<Integer> 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) {
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<FeedbackFragment> currentFragmentIterator, String feedBackItemUtteranceId) {
this.currentFragmentIterator = currentFragmentIterator;
Expand All @@ -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);
}
Expand Down Expand Up @@ -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<FeedbackFragment>) list.clone()).iterator();

Expand Down
Loading
Loading