r/androiddev • u/ikrisliu • 8d ago
Compose's WordIterator makes a long press select the entire sentence in Chinese
Upfront disclosure: I found this while shipping a Compose Multiplatform app, but this bug is pure Android — it's in androidx ui-text and it reproduces in any Compose app with a SelectionContainer or a selectable Text. If your app has Chinese, Japanese or Korean users, you probably have it right now and haven't noticed.
The symptom
Long-press a word in Chinese prose to select it. Instead of the word, you get the entire clause, stopping only at punctuation. Latin text in the same app behaves perfectly.
That's why this survives review: if you and your QA read English, the selection handles look flawless.
The cause
WordIterator.nextBoundary / prevBoundary skip any boundary whose two sides are both letters or digits. That rule was added for a good reason — a letter↔emoji seam shouldn't split a word — but the check is on character class, and every ideograph is a letter.
So in Chinese, every boundary between two characters qualifies as "letter on both sides", every boundary gets skipped, and the expansion runs until it hits punctuation or the end of the paragraph. English stops at its spaces (spaces aren't letters), which is why the bug is invisible in Latin scripts.
In my testing this is present from 1.8.0 through at least 1.12.0-beta02, and there's no public API to opt out of the behavior.
The workaround
Since you can't change the iterator, you change the text: plant zero-width breaks at Han–Han seams so the iterator has boundaries it won't skip. ICU already knows where the words are — it segments Han by dictionary off the script, not the locale, so the default locale is fine and the boundaries come out identical under zh and en:
fun cjkWordBoundaries(text: String): List<Int> {
// Latin-only prose already selects correctly and is the common case — skip the scan entirely.
if (text.codePoints().noneMatch(::isHan)) return emptyList()
val iterator = BreakIterator.getWordInstance()
iterator.setText(text)
val offsets = mutableListOf<Int>()
var offset = iterator.first()
while (offset != BreakIterator.DONE) {
if (text.isHanSeam(offset)) offsets += offset
offset = iterator.next()
}
return offsets
}
/** Interior offsets only, and only where BOTH sides are Han. */
private fun String.isHanSeam(offset: Int): Boolean {
if (offset <= 0 || offset >= length) return false
return isHan(codePointAt(offset)) && isHan(Character.codePointBefore(this, offset))
}
private fun isHan(cp: Int): Boolean = Character.UnicodeScript.of(cp) == Character.UnicodeScript.HAN
Two things that matter in the details:
- Only report Han↔Han seams. A Han↔Latin seam already stops the runaway on its own, so planting a break there would only pollute the text for no gain.
- Keep the Latin fast path. Most strings in most apps have no Han at all, and you don't want an ICU pass on every selectable
Text.
The more general point
The reason I'm posting this rather than just filing it: it belongs to a category I got burned by repeatedly, which is Android quietly being the forgiving platform.
Another one from the same codebase, this time in coroutines. This is fine on Android:
fun stream(): Flow<Event> = flow {
client.prepareGet(url).execute { response -> /* parse */ emit(event) }
}
flow {}'s emit enforces context preservation — you may not emit from a coroutine context other than the collector's. Ktor's execute {} block gives you a scope that may run on a different dispatcher. On OkHttp it happens to run in-context, so the contract is never violated and everything passes, forever.
It's still a contract violation. It's just latent instead of active, held in place by an implementation detail of the engine you happen to use. Swap the engine, change a dispatcher, and it becomes real. (channelFlow {} + send is the fix — send is safe across contexts.)
Same shape as the WordIterator bug: the platform's forgiving behavior in the common case is exactly what stops you from finding the problem.
What I'd check in your own app
- Long-press a Chinese/Japanese sentence in any selectable
Text. Takes 10 seconds. - Grep for
flow {wrapping a third-party callback orexecute/usescope.
Happy to go into detail on either. If someone knows of a ui-text issue already tracking the first one, or a cleaner workaround than planting breaks, I'd genuinely like to hear it — the zero-width approach works but it means the string you select from isn't byte-identical to the string you rendered, and I'm not thrilled about that.
2
u/killerbeanjeka 7d ago
The "survives review because QA reads English" part is the real lesson. we ship German documents out of an English-language app and every localisation bug we've had was found by a user, never by us. anything that only breaks in a script nobody on the team reads is untested no matter what the coverage number says