diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIConstants.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIConstants.kt index f00d650e..1a15a497 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIConstants.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIConstants.kt @@ -1,9 +1,19 @@ package com.bitchat.android.ui +import java.text.BreakIterator + /** * UI constants/utilities for nickname rendering. */ fun truncateNickname(name: String, maxLen: Int = com.bitchat.android.util.AppConstants.UI.MAX_NICKNAME_LENGTH): String { - return if (name.length <= maxLen) name else name.take(maxLen) -} + if (name.length <= maxLen) return name + // The limit counts UTF-16 code units, so cutting at it can land inside a + // surrogate pair and leave a lone surrogate that renders as a tofu box, or + // split a ZWJ sequence into its parts. Step back to the last grapheme + // boundary at or before the limit so what is shown is always whole. + val boundaries = BreakIterator.getCharacterInstance() + boundaries.setText(name) + val end = boundaries.preceding(maxLen + 1) + return if (end == BreakIterator.DONE) "" else name.substring(0, end) +} diff --git a/app/src/test/java/com/bitchat/android/ui/ChatUIConstantsTest.kt b/app/src/test/java/com/bitchat/android/ui/ChatUIConstantsTest.kt new file mode 100644 index 00000000..647fb2c8 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIConstantsTest.kt @@ -0,0 +1,79 @@ +package com.bitchat.android.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatUIConstantsTest { + + @Test + fun `a name inside the limit is returned unchanged`() { + assertEquals("alice", truncateNickname("alice", maxLen = 15)) + assertEquals("exactlyfifteen!", truncateNickname("exactlyfifteen!", maxLen = 15)) + } + + @Test + fun `an ascii name is still cut at the limit`() { + assertEquals("hello world thi", truncateNickname("hello world this is long", maxLen = 15)) + } + + /** + * The limit counts UTF-16 code units, so the 15th unit of this name is the + * high surrogate of the emoji. Cutting there leaves a lone surrogate that + * renders as a tofu box. + */ + @Test + fun `a surrogate pair is never split`() { + val truncated = truncateNickname("abcdefghijklmn😀", maxLen = 15) + + assertEquals("abcdefghijklmn", truncated) + assertTrue( + "truncated name must not end in a lone surrogate", + truncated.none { Character.isSurrogate(it) } + ) + } + + @Test + fun `an emoji that fits is kept whole`() { + assertEquals("abcdefghijkl😀m", truncateNickname("abcdefghijkl😀mno", maxLen = 15)) + } + + /** A ZWJ sequence is one grapheme, so it is kept or dropped as a whole. */ + @Test + fun `a zwj sequence is not split into its parts`() { + val family = "👨‍👩‍👧" + val truncated = truncateNickname(family + " family chat", maxLen = 15) + + assertEquals(family + " family", truncated) + assertTrue("the family must survive whole", truncated.startsWith(family)) + } + + @Test + fun `a name made only of one long grapheme truncates to empty rather than half a character`() { + val family = "👨‍👩‍👧‍👦" + val truncated = truncateNickname(family, maxLen = 5) + + assertEquals("", truncated) + } + + @Test + fun `every truncation stays within the limit and holds no partial character`() { + val samples = listOf( + "abcdefghijklmn😀", + "🇮🇳 india mesh", + "👍🏽 thumbs up all round", + "plain long nickname here" + ) + for (name in samples) { + for (limit in 1..20) { + val truncated = truncateNickname(name, maxLen = limit) + assertTrue("$name at $limit exceeded the limit", truncated.length <= limit) + assertTrue("$name at $limit is not a prefix of the name", name.startsWith(truncated)) + assertTrue( + "$name at $limit ended in a lone surrogate", + truncated.isEmpty() || !Character.isHighSurrogate(truncated.last()) + ) + } + } + } +}