Skip to content

Normalize Chinese locale tags when looking up phonetic letters, and announce Latin letters with their position in zh-CN - #56

Merged
aaron-gh merged 5 commits into
aaron-gh:masterfrom
XHG20033:phonetic-locale-normalization
Nov 11, 2026
Merged

aaron-gh merged 5 commits into
aaron-gh:masterfrom
XHG20033:phonetic-locale-normalization

Conversation

@XHG20033

@XHG20033 XHG20033 commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Resolves #44 (first step).

Locale normalization. Devices report Chinese locales in more shapes than the JSON's two keys (zh-CN / zh-TW) cover. A device reporting zh-Hans-CN, for example, previously fell through to the plain zh node, so the zh-CN letter entries never played. ProcessorPhoneticLetters now normalizes tags before lookup:

  • Script subtags win: zh-Hans* → zh-CN, zh-Hant* → zh-TW
  • Region-only tags: CN/SG → zh-CN, TW/HK/MO → zh-TW
  • On conflict the script wins (zh-Hans-MO → zh-CN)
  • Anything else keeps the existing fallback to zh

LocaleUtils.parseLocaleString doesn't parse script subtags yet (see the TODO referencing b/455704867), so the method parses the tag itself; covered by ProcessorPhoneticLettersLocaleTest (13 tag shapes, conflict cases, casing, non-Chinese pass-through).

Latin letters in zh-CN only. The 26 letter entries in zh-CN are prepended with their position ("第1个字母,alpha"), matching the format commonly used by screen readers in China. Note that every consumer of getPhoneticLetter reads this node, so character navigation, spelling, typed-key echo, keyboard focus and "Announce phonetic pronunciation" all include the position prefix; the other nodes (zh, zh-TW) are untouched.

Provenance: no third-party word lists are involved; the entries are the project's own Apache-2.0 data with positions added.

Tested: built via fork CI (phone-full + unit tests green). On-device with a zh-Hans-CN device, letter navigation now announces "第1个字母, alpha". Manually switching the device to Traditional Chinese confirmed zh-TW behavior is unchanged.

AI disclosure: AI assistance was limited to GitHub operations. The changes themselves were designed in discussion in #44 and manually reviewed and tested.

The phonetic letters JSON only has zh, zh-CN and zh-TW nodes, and the
lookup used the raw BCP-47 tag, so many Chinese locales never matched:
zh-Hans-CN (reported by many Chinese ROMs such as Honor/Huawei),
zh-Hans, zh-Hant-TW, zh-HK, zh-MO and others all fell through to the
zh node - simplified users got the inherited table and traditional
users on zh-Hant-* locales got simplified-style descriptions.

Normalize the tag before lookup: script Hans or region CN/SG resolves
to zh-CN, script Hant or region TW/HK/MO resolves to zh-TW (script
takes priority), and anything else keeps the existing behavior
(bare zh uses the zh node; per-entry fallback to zh still applies).
Unit tests cover the full tag matrix.
Prepend the letter's position (e.g. "第1个字母, alpha") to the 26 Latin
letter entries in the zh-CN node, matching the format commonly used by
screen readers in China. With locale normalization in place, devices
reporting zh-Hans-CN or zh-Hans now reach these entries directly.

@aaron-gh aaron-gh left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No functional defects found in the normalization logic. Checked against getPhoneticLetter and LocaleUtils.parseLocaleString: for zh-Hans-CN the parsed locale is zh/HANS, so a letter missing from the normalized node still reaches the base-language zh fallback because the country is non-empty. The map cache is now keyed by the normalized tag, which is correct. Extension and private-use tags (zh-CN-x-foo, zh-u-ca-chinese) end up at the right node or at zh.

Minor points:

  • ProcessorPhoneticLetters.java, normalizePhoneticLocale: the subtags.length == 0 check is dead, because String.split with a non-empty input always returns at least one element. For an empty string subtags[0] is "" and is handled by the equalsIgnoreCase check.
  • Same method: the TW/HK/MO condition line is over 100 columns. Wrap it to match the file's formatting.
  • Same method: split("-", 3) plus the length-4 and length-2 checks accept anything of that length, including the tail of an extension such as x-ab. This is harmless today because only exact Hans/Hant/CN/SG/TW/HK/MO values match. Stopping at the first singleton subtag (x, u) would be stricter.
  • getPhoneticLetter still builds LocaleSpan and lowercases with the un-normalized parsedLocale (zh/HANS). This predates the PR, but the spoken text for zh-Hans-CN is now zh-CN content tagged with an odd locale. Consider using the normalized tag for parsedLocale.
  • phonetic_letters.json: the position prefix applies to every consumer of the zh-CN node, not only letter navigation. That includes getPhoneticText and the typing and keyboard-focus paths that substitute the phonetic letter for the letter (getPhoneticLetterTakesPlaceOfLetter). Those will now speak 第N个字母, alpha too. Confirm this is intended.
  • ProcessorPhoneticLettersLocaleTest.kt: the test only calls a static pure function, so RobolectricTestRunner and @Config are unnecessary and slow it down. A plain JUnit runner would do. No test covers the getPhoneticLetter path, so the zh-CN data change and the lookup change are untested together.

Not compiled or tested.

- Drop dead subtags.length == 0 check (String.split never returns an
  empty array for non-empty input)
- Wrap the region condition to match the file's 100-column limit
- Stop at singleton subtags (x-, u-) so extension or private-use tails
  are never mistaken for script or region subtags
- Derive the parsed locale used for lowercasing and the LocaleSpan from
  the normalized tag instead of the raw locale
- Test the locale normalization with a plain JUnit runner and cover
  extension/private-use tags
- Add an end-to-end lookup test over the real phonetic_letters.json
  covering the zh-CN letters, the zh fallback for Chinese characters
  and zh-TW's Latin-letter fallback
@XHG20033

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review — all points addressed in 0f4e704:

  • Dead check: dropped subtags.length == 0, you're right that String.split always returns at least one element for non-empty input, and the empty string is handled by equalsIgnoreCase.
  • Formatting: the region condition is now wrapped to match the file's 100-column limit.
  • Singleton subtags: the subtag loop now stops at the first singleton (x, u), so extension/private-use tails are never mistaken for a script or region. Added cases for zh-CN-x-foo, zh-x-Hans, zh-u-ca-chinese and zh-Hans-x-foo.
  • Parsed locale: getPhoneticLetter now derives the locale used for lowercasing and the LocaleSpan from the normalized tag, so zh-Hans-CN no longer ends up with the odd zh/HANS parse attached to the spoken text.
  • Position prefix: intended. The goal is for every zh-CN letter announcement to carry its position, since single-letter disambiguation matters in Chinese regardless of context — navigation, typing and spelling alike. We considered scoping it to traversal only, but it also seems useful in the takes-place-of-letter mode that the user explicitly opted into. Happy to revisit if you'd prefer the substitution paths to stay terse.
  • Tests: the locale test now runs on a plain JUnit runner. For the lookup path, the chain is extracted into @VisibleForTesting static getPhoneticLetterWithMap(locale, letter, mapLoader) (the instance method delegates with the real loader), and a new Robolectric test feeds it the real phonetic_letters.json to cover normalization + node lookup + fallbacks against actual data: zh-CN position-prefixed letters (a → 第1个字母, alpha), the zh fallback for Chinese characters (大 → 大小) and zh-TW's Latin-letter fallback to the zh node (a → Alfa).

Unit tests for these changes are running on our fork's test-build branch: https://github.com/XHG20033/SandFly/actions/runs/42854209554

(Note: this reply and the accompanying changes were drafted with AI assistance and reviewed by @XHG20033.)

The zh-Hans-CN fallback case assumed the zh-CN node has no Chinese
characters, which no longer holds on branches with expanded zh-CN data.
Use a synthetic loader so the base-language fallback path is tested
regardless of the resource contents.
@XHG20033
XHG20033 force-pushed the phonetic-locale-normalization branch from 0f4e704 to 6e2e45d Compare November 10, 2026 16:32

@aaron-gh aaron-gh left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No defects found in normalizePhoneticLocale; subtag handling, singleton cut-off and script-over-region priority behave as the tests describe. Points on the data change:

  1. talkback/src/main/res/raw/phonetic_letters.json, zh-CN node: the position prefix is stored in the shared letter data, so it applies to every consumer of getPhoneticLetter, not only letter navigation. getPhoneticText (ProcessorPhoneticLetters.java, around lines 337-359) builds a: 第1个字母, alpha and joins entries with ", ", giving a: 第1个字母, alpha, b: 第2个字母, bravo. The separator inside each entry is the same as the one between entries, so entries are harder to tell apart when spelled out. A different inner separator, or applying the prefix only in the traversal path, avoids this.

  2. Same node: with getPhoneticLetterTakesPlaceOfLetter enabled (lines 243 and 260), typed-key echo and focus announcements for zh-CN now always include the position. That is intentional per the PR discussion, but it is a behaviour change for all zh-CN users of those paths, including those who already get zh-Hans-CN resolved to zh and a different result before.

  3. The Chinese text is hard-coded in the JSON rather than in a string resource, so it cannot be adjusted per locale variant. zh-TW and the zh fallback do not get an equivalent prefix, so Traditional Chinese devices that now resolve to zh-TW remain inconsistent with zh-CN.

  4. ProcessorPhoneticLettersLookupTest.kt, zhTwLettersFallBackToZhNode: depends on the zh node keeping "a": "Alfa"; the other tests in that file use synthetic data for exactly that reason, so this one is inconsistent and will break on unrelated data edits.

@aaron-gh aaron-gh left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested.

  1. ProcessorPhoneticLetters.java:431, 453: the LocaleSpan is now built from the normalized node key, so zh-HK and zh-MO text is marked zh_TW, and zh-SG text zh_CN. Switch language automatically and Switch dialect automatically are on by default, so LanguageSwitch keeps the mark, and a zh-HK user hears the description in a separate utterance with the zh-TW voice. Use the normalized tag only to choose the JSON node and for the base-language fallback, and mark the result with Locale.forLanguageTag(locale) from the original tag. This replaces the earlier suggestion to use the normalized tag for parsedLocale.
  2. ProcessorPhoneticLettersLookupTest.kt: assert the LocaleSpan locale as well as the text, at least for zh-HK and zh-Hans-CN.
  3. phonetic_letters.json:59177-59202: #44 proposed Alfa,第1个字母. The zh-TW data and the agreed template ("长短的长,长大的长") use the fullwidth ,, and no Chinese entry uses , . Use ,, and put the word first as in #44, or state why position first is better.
  4. normalizePhoneticLocale returns null without @Nullable on the return type. All callers pass toLanguageTag() output, so remove the null branch or annotate it.
  5. PR description: "Spelling ... untouched" is incorrect. Spelling, typing, keyboard keys and Announce phonetic pronunciation all read the zh-CN node and now include the position. Update the description.

- The normalized tag now only chooses the JSON node and drives the
  base-language fallback; the LocaleSpan and lowercasing use the original
  tag, so zh-HK/zh-MO/zh-SG users hear the description in their own voice
  instead of the voice of the node the tag maps to
- Assert the LocaleSpan locale for zh-HK and zh-Hans-CN
- Use the fullwidth comma in the zh-CN letter entries, consistent with
  the rest of the Chinese entries
- Drop the null branch of normalizePhoneticLocale; callers always pass
  toLanguageTag() output
- Use synthetic data in the zh-TW fallback test so it does not depend on
  the JSON contents
@XHG20033

XHG20033 commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor Author

All five points addressed in ae16bcf:

  1. LocaleSpan: the normalized tag now only chooses the JSON node and drives the base-language fallback. The LocaleSpan and the lowercasing locale are derived from the original tag via Locale.forLanguageTag(locale), so a zh-HK/zh-MO user still hears the description with their own voice even though the text comes from the zh-TW node (and likewise zh-SG keeps zh_CN over the zh-CN node).

  2. Tests: zhHansCnMarksResultWithOriginalLocale asserts the span locale for zh-Hans-CN, and the new zhHkSharesZhTwNodeButKeepsItsOwnLocaleSpan asserts both the text (Alfa, via the zh-TW→zh fallback) and the zh-HK span locale.

  3. Fullwidth comma: the 26 entries now use , (a → 第1个字母,alpha), consistent with the rest of the Chinese entries. We kept the position first rather than the Suggestions for the interpretation of Chinese characters. #44 word-first order: the position-first format was specifically requested by Simplified Chinese screen reader users we tested with — hearing the index first immediately tells them which letter they are on (the NATO word alone doesn't map to a letter for most Chinese users, which is exactly why the index exists), and the word afterwards serves as pronunciation confirmation. Separately, the fullwidth , also resolves the inner/outer separator collision in getPhoneticText: entries joined with ", " now read a: 第1个字母,alpha, b: 第2个字母,bravo, where the inner , no longer looks identical to the joiner. That said, the audible difference is negligible either way — TTS renders , and , pauses almost identically — so this is a text-level consistency improvement rather than an audible one.

  4. Null branch: removed. normalizePhoneticLocale now takes a non-null String (callers always pass toLanguageTag() output), and its @Nullable return annotation is gone too since it never returns null.

  5. PR description: the "Spelling ... untouched" sentence is corrected — it now states that every consumer of getPhoneticLetter (character navigation, spelling, typed-key echo, keyboard focus, phonetic pronunciation) includes the position prefix.

Also from your earlier notes: zhTwLettersFallBackToZhNode now uses a synthetic loader like the other value-pinning tests, so no test in the file depends on the JSON contents anymore. The zh-TW/zh nodes intentionally stay prefix-free in this PR. This follows your own requirement in #44: you asked that the changes be made under the zh-CN node. That instruction was about Chinese-character explanations, but since the position-prefixed letter format is likewise a Simplified-Chinese user habit, I followed the same rule and added the letter explanations under zh-CN as well. I am doing this on behalf of Simplified Chinese users — if this ever affects Traditional users and someone finds the result unfamiliar, changing it back is not something I owe.

Unit tests for these changes are running on our fork's test-build branch: https://github.com/XHG20033/SandFly/actions/runs/42941195595

(Note: this reply and the accompanying changes were drafted with AI assistance and reviewed by @XHG20033.)

@XHG20033
XHG20033 force-pushed the phonetic-locale-normalization branch from ab2f055 to ae16bcf Compare November 11, 2026 02:15
@aaron-gh
aaron-gh merged commit ffb68f3 into aaron-gh:master Nov 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Suggestions for the interpretation of Chinese characters.

2 participants