fix(android): preserve graphemes in compact badges (#109486)

* fix(android): preserve graphemes in compact badges

* chore(android): satisfy native source gates

* fix(android): keep compact badge casing stable
This commit is contained in:
Leon-SK668
2026-07-18 16:27:07 +08:00
committed by GitHub
parent 6032dec3ba
commit d1153e4e2b
9 changed files with 496 additions and 471 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -7426,7 +7426,7 @@ class NodeRuntime private constructor(
.split(' ', '-', '_')
.filter { it.isNotBlank() }
.take(2)
.mapNotNull { token -> token.firstOrNull()?.uppercaseChar()?.toString() }
.mapNotNull { token -> token.uppercaseFirstGraphemeOrNull() }
.joinToString("")
return if (initials.isNotEmpty()) initials else "OC"
}

View File

@@ -15,6 +15,14 @@ internal fun String.firstGraphemeOrNull(): String? {
return if (end == BreakIterator.DONE) null else substring(0, end)
}
internal fun String.uppercaseFirstGraphemeOrNull(): String? {
val grapheme = firstGraphemeOrNull() ?: return null
val firstCodePoint = grapheme.codePointAt(0)
val uppercaseCodePoint = Character.toUpperCase(firstCodePoint)
// Keep badge width stable while preserving the rest of the grapheme cluster.
return String(Character.toChars(uppercaseCodePoint)) + grapheme.substring(Character.charCount(firstCodePoint))
}
internal fun String.takeUtf16Safe(maxChars: Int): String {
if (length <= maxChars) return this
// Keep the code-unit cap without leaving a high surrogate at its boundary.

View File

@@ -12,6 +12,7 @@ import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTextBadge
import ai.openclaw.app.ui.design.ClawTheme
import ai.openclaw.app.uppercaseFirstGraphemeOrNull
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -156,7 +157,7 @@ private fun channelBadge(label: String): String =
.split(' ', '-', '_')
.filter { it.isNotBlank() }
.take(2)
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
.mapNotNull { it.uppercaseFirstGraphemeOrNull() }
.joinToString("")
.ifBlank { "C" }

View File

@@ -16,6 +16,7 @@ import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTextBadge
import ai.openclaw.app.ui.design.ClawTheme
import ai.openclaw.app.uppercaseFirstGraphemeOrNull
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -340,7 +341,7 @@ private fun nodeBadge(value: String): String =
.split(' ', '-', '_')
.filter { it.isNotBlank() }
.take(2)
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
.mapNotNull { it.uppercaseFirstGraphemeOrNull() }
.joinToString("")
.ifBlank { "N" }

View File

@@ -11,6 +11,7 @@ import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawScaffold
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawTheme
import ai.openclaw.app.uppercaseFirstGraphemeOrNull
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -417,7 +418,7 @@ private fun providerInitials(value: String): String =
.split(' ', '-', '_')
.filter { it.isNotBlank() }
.take(2)
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
.mapNotNull { it.uppercaseFirstGraphemeOrNull() }
.joinToString("")
.ifBlank { "AI" }

View File

@@ -64,6 +64,7 @@ import ai.openclaw.app.ui.design.OpenClawMascot
import ai.openclaw.app.ui.design.TalkWaveform
import ai.openclaw.app.ui.design.TalkWaveformPhase
import ai.openclaw.app.ui.design.agentAvatarSource
import ai.openclaw.app.uppercaseFirstGraphemeOrNull
import ai.openclaw.app.voice.VoiceWakePreferences
import android.Manifest
import android.content.ClipData
@@ -2451,7 +2452,7 @@ private fun UsageProviderListRow(provider: GatewayUsageProviderSummary) {
ClawDetailRow(
title = provider.displayName,
subtitle = usageProviderSubtitle(provider),
leading = { ClawTextBadge(text = provider.displayName.firstOrNull()?.uppercase() ?: "U") },
leading = { ClawTextBadge(text = provider.displayName.uppercaseFirstGraphemeOrNull() ?: "U") },
trailing = { ClawStatusPill(text = if (hasIssue) nativeString("Issue") else "OK", status = if (hasIssue) ClawStatus.Warning else ClawStatus.Success) },
)
}
@@ -2680,7 +2681,7 @@ private fun agentBadge(agent: GatewayAgentSummary): String {
.split(' ', '-', '_')
.filter { it.isNotBlank() }
.take(2)
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
.mapNotNull { it.uppercaseFirstGraphemeOrNull() }
.joinToString("")
.ifBlank { "A" }
}
@@ -2937,7 +2938,7 @@ private fun notificationAppBadge(label: String): String {
.asSequence()
.filter { it.isNotBlank() }
.take(2)
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
.mapNotNull { it.uppercaseFirstGraphemeOrNull() }
.joinToString("")
return initials.ifBlank { "A" }
}

View File

@@ -22,6 +22,7 @@ import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTextBadge
import ai.openclaw.app.ui.design.ClawTextField
import ai.openclaw.app.ui.design.ClawTheme
import ai.openclaw.app.uppercaseFirstGraphemeOrNull
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
@@ -893,6 +894,6 @@ private fun skillBadge(name: String): String =
.split(' ', '-', '_')
.filter { it.isNotBlank() }
.take(2)
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
.mapNotNull { it.uppercaseFirstGraphemeOrNull() }
.joinToString("")
.ifBlank { "S" }

View File

@@ -22,6 +22,18 @@ class Utf16TextTest {
assertNull("".firstGraphemeOrNull())
}
@Test
fun uppercaseFirstGraphemeOrNullPreservesUserPerceivedCharacters() {
assertEquals("🧭", "🧭 Scout".uppercaseFirstGraphemeOrNull())
assertEquals("🇺🇸", "🇺🇸 Scout".uppercaseFirstGraphemeOrNull())
assertEquals("👩🏽‍💻", "👩🏽‍💻 Dev".uppercaseFirstGraphemeOrNull())
assertEquals("A\u0308", "a\u0308lice".uppercaseFirstGraphemeOrNull())
assertEquals("S", "scout".uppercaseFirstGraphemeOrNull())
assertEquals("ß", "ßcout".uppercaseFirstGraphemeOrNull())
assertEquals("\uD801\uDC00", "\uD801\uDC28cout".uppercaseFirstGraphemeOrNull())
assertNull("".uppercaseFirstGraphemeOrNull())
}
@Test
fun localizedInitialPreservesGraphemesAndLocale() {
assertEquals("🧭", localizedInitial("🧭 Scout", languageTag = "en", fallbackLocale = Locale.US))