Merge 94872734a3c800fdff66849ec41d361062920600 into c127eb83ab94c069c32d37530d2faecd381cd2a8

This commit is contained in:
Taksh Kothari 2026-09-14 09:43:25 +05:30 committed by GitHub
commit 9fe5cc3764
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 91 additions and 2 deletions

View File

@ -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)
}

View File

@ -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())
)
}
}
}
}