From 316d0e67c58a010646303d15b806058f8c11b39f Mon Sep 17 00:00:00 2001 From: jeffrey-signal Date: Thu, 26 Feb 2026 08:32:32 -0500 Subject: [PATCH] Enforce member label emoji and text constraints. --- .../groups/memberlabel/MemberLabel.kt | 50 ++++- .../groups/memberlabel/MemberLabelFragment.kt | 2 +- .../groups/memberlabel/MemberLabelPillView.kt | 2 +- .../memberlabel/MemberLabelRepository.kt | 12 +- .../memberlabel/MemberLabelViewModel.kt | 21 ++- .../groups/memberlabel/SenderNameWithLabel.kt | 4 +- .../recipients/ui/about/AboutSheet.kt | 2 +- .../MemberLabelEmojiValidationTest.kt | 71 +++++++ .../MemberLabelSanitizationTest.kt | 177 ++++++++++++++++++ .../memberlabel/MemberLabelViewModelTest.kt | 85 +++++++++ 10 files changed, 407 insertions(+), 19 deletions(-) create mode 100644 app/src/test/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelEmojiValidationTest.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelSanitizationTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabel.kt index cce7b1ff89..bee24ef873 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabel.kt @@ -6,6 +6,10 @@ package org.thoughtcrime.securesms.groups.memberlabel import androidx.annotation.ColorInt +import org.signal.core.util.BidiUtil +import org.signal.core.util.BreakIteratorCompat +import org.signal.core.util.StringUtil +import org.thoughtcrime.securesms.components.emoji.EmojiUtil /** * A member's custom label within a group. @@ -13,7 +17,51 @@ import androidx.annotation.ColorInt data class MemberLabel( val emoji: String?, val text: String -) +) { + + /** + * The label text formatted for display. + * + * Use this in all rendering contexts. Use [text] only when comparing or persisting values. + */ + val displayText: String get() = BidiUtil.isolateBidi(text) + + companion object { + const val MIN_LABEL_GRAPHEMES = 1 + const val MAX_LABEL_GRAPHEMES = 24 + const val MAX_LABEL_BYTES = 96 + const val MAX_EMOJI_BYTES = 48 + + /** + * Truncates label [text] to the grapheme and byte limits without any whitespace normalization. + */ + @JvmStatic + fun truncateLabelText(text: String): String { + val breakIterator = BreakIteratorCompat.getInstance() + breakIterator.setText(text) + val graphemeTruncated = breakIterator.take(MAX_LABEL_GRAPHEMES).toString() + return StringUtil.trimToFit(graphemeTruncated, MAX_LABEL_BYTES) + } + + /** + * Sanitizes and truncates label [text]. + */ + @JvmStatic + fun sanitizeLabelText(text: String): String { + val collapsed = StringUtil.trimToVisualBounds(text.replace(Regex("\\s+"), " ")) + return truncateLabelText(collapsed) + } + + /** + * Returns [emoji] if it is a single valid emoji within [MAX_EMOJI_BYTES], otherwise null. + */ + @JvmStatic + fun sanitizeEmoji(emoji: String?): String? { + val trimmed = StringUtil.trimToFit(emoji, MAX_EMOJI_BYTES) + return trimmed.takeIf { it.isNotBlank() && EmojiUtil.isEmoji(it) } + } + } +} data class StyledMemberLabel( val label: MemberLabel, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelFragment.kt index 36e7870e9d..9c73728484 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelFragment.kt @@ -185,7 +185,7 @@ private fun MemberLabelScreenUi( sender = state.recipient, senderNameColor = state.senderNameColor, labelEmoji = state.labelEmoji, - labelText = state.labelText, + labelText = state.sanitizedLabelText, messageText = stringResource(R.string.GroupMemberLabel__preview_sample_message) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelPillView.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelPillView.kt index 9da9aeb3fc..eff1ab3e0a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelPillView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelPillView.kt @@ -53,7 +53,7 @@ class MemberLabelPillView : AbstractComposeView { memberLabel?.let { label -> MemberLabelPill( emoji = label.emoji, - text = label.text, + text = label.displayText, tintColor = tintColor, modifier = Modifier.padding(horizontal = style.horizontalPadding, vertical = style.verticalPadding), textStyle = style.textStyle() diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelRepository.kt index 3d2154f323..358baabb5d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelRepository.kt @@ -68,7 +68,7 @@ class MemberLabelRepository private constructor( val aci = recipient.serviceId.orNull() as? ServiceId.ACI ?: return@withContext null val groupRecord = groupsTable.getGroup(groupId).orNull() ?: return@withContext null - return@withContext groupRecord.requireV2GroupProperties().memberLabel(aci) + groupRecord.requireV2GroupProperties().memberLabel(aci)?.sanitized() } /** @@ -87,7 +87,7 @@ class MemberLabelRepository private constructor( buildMap { recipients.forEach { recipient -> val aci = recipient.serviceId.orNull() as? ServiceId.ACI - labelsByAci[aci]?.let { label -> put(recipient.id, label) } + labelsByAci[aci]?.let { label -> put(recipient.id, label.sanitized()) } } } } @@ -122,6 +122,12 @@ class MemberLabelRepository private constructor( throw IllegalStateException("Set member label not allowed due to remote config.") } - GroupManager.updateMemberLabel(context, groupId, label.text, label.emoji.orEmpty()) + val sanitizedLabel = label.sanitized() + GroupManager.updateMemberLabel(context, groupId, sanitizedLabel.text, sanitizedLabel.emoji.orEmpty()) } } + +private fun MemberLabel.sanitized(): MemberLabel = this.copy( + emoji = MemberLabel.sanitizeEmoji(this.emoji), + text = MemberLabel.sanitizeLabelText(this.text) +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModel.kt index 9c24e2f047..cbfb62cbba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.signal.core.util.StringUtil import org.signal.core.util.concurrent.SignalDispatchers import org.thoughtcrime.securesms.conversation.colors.NameColor import org.thoughtcrime.securesms.groups.GroupId @@ -19,9 +20,6 @@ import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelUiState.SaveStat import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId -private const val MIN_LABEL_TEXT_LENGTH = 1 -private const val MAX_LABEL_TEXT_LENGTH = 24 - class MemberLabelViewModel( private val memberLabelRepo: MemberLabelRepository = MemberLabelRepository.instance, private val groupId: GroupId.V2, @@ -65,11 +63,11 @@ class MemberLabelViewModel( } fun onLabelTextChanged(text: String) { - val sanitizedText = text.take(MAX_LABEL_TEXT_LENGTH) + val truncatedText = MemberLabel.truncateLabelText(text) internalUiState.update { it.copy( - labelText = sanitizedText, - hasChanges = hasChanges(labelEmoji = it.labelEmoji, labelText = sanitizedText) + labelText = truncatedText, + hasChanges = hasChanges(labelEmoji = it.labelEmoji, labelText = truncatedText) ) } } @@ -85,7 +83,7 @@ class MemberLabelViewModel( } private fun hasChanges(labelEmoji: String, labelText: String): Boolean { - return labelEmoji != originalLabelEmoji || labelText != originalLabelText + return labelEmoji != originalLabelEmoji || MemberLabel.sanitizeLabelText(labelText) != originalLabelText } fun save() { @@ -128,13 +126,16 @@ data class MemberLabelUiState( val hasChanges: Boolean = false, val saveState: SaveState? = null ) { + val sanitizedLabelText: String get() = MemberLabel.sanitizeLabelText(labelText) + val remainingCharacters: Int - get() = MAX_LABEL_TEXT_LENGTH - labelText.length + get() = MemberLabel.MAX_LABEL_GRAPHEMES - StringUtil.getGraphemeCount(sanitizedLabelText) val isSaveEnabled: Boolean get() { - val isCleared = labelText.isEmpty() && labelEmoji.isEmpty() - val hasValidLabel = labelText.length in MIN_LABEL_TEXT_LENGTH..MAX_LABEL_TEXT_LENGTH + val isCleared = sanitizedLabelText.isEmpty() && labelEmoji.isEmpty() + val graphemeCount = StringUtil.getGraphemeCount(sanitizedLabelText) + val hasValidLabel = graphemeCount in MemberLabel.MIN_LABEL_GRAPHEMES..MemberLabel.MAX_LABEL_GRAPHEMES return hasChanges && (hasValidLabel || isCleared) && saveState != SaveState.InProgress } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/SenderNameWithLabel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/SenderNameWithLabel.kt index 0697bcddf6..7262879fc6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/SenderNameWithLabel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/memberlabel/SenderNameWithLabel.kt @@ -48,7 +48,7 @@ fun SenderNameWithLabel( labelSlot = { label -> MemberLabelPill( emoji = label.emoji, - text = label.text, + text = label.displayText, tintColor = senderColor, modifier = defaultLabelModifier, textStyle = defaultLabelTextStyle() @@ -78,7 +78,7 @@ fun SenderNameWithLabel( labelSlot = { label -> MemberLabelPill( emoji = label.emoji, - text = label.text, + text = label.displayText, textColor = labelTextColor, backgroundColor = labelBackgroundColor, modifier = defaultLabelModifier, diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt index 21a74880dc..5505791a54 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/about/AboutSheet.kt @@ -381,7 +381,7 @@ private fun MemberLabelRow( } Text( - text = memberLabel.text, + text = memberLabel.displayText, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelEmojiValidationTest.kt b/app/src/test/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelEmojiValidationTest.kt new file mode 100644 index 0000000000..b04abc0058 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelEmojiValidationTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.groups.memberlabel + +import android.app.Application +import io.mockk.every +import io.mockk.mockkObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.thoughtcrime.securesms.emoji.EmojiSource +import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class MemberLabelEmojiValidationTest { + + @get:Rule + val appDependencies = MockAppDependenciesRule() + + private fun withEmojiSource(block: () -> Unit) { + val source = EmojiSource.loadAssetBasedEmojis() + mockkObject(EmojiSource) { + every { EmojiSource.latest } returns source + block() + } + } + + @Test + fun `sanitizeEmoji returns valid emoji unchanged`() = withEmojiSource { + assertEquals("\uD83D\uDE0D", MemberLabel.sanitizeEmoji("\uD83D\uDE0D")) // ๐Ÿ˜ + } + + @Test + fun `sanitizeEmoji returns valid ZWJ sequence unchanged`() = withEmojiSource { + val familyEmoji = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66" // ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€ + assertEquals(familyEmoji, MemberLabel.sanitizeEmoji(familyEmoji)) + } + + @Test + fun `sanitizeEmoji returns null for plain text`() = withEmojiSource { + assertNull(MemberLabel.sanitizeEmoji("hello")) + } + + @Test + fun `sanitizeEmoji returns null for multiple emojis`() = withEmojiSource { + assertNull(MemberLabel.sanitizeEmoji("\uD83D\uDE0D\uD83D\uDE0D")) // ๐Ÿ˜๐Ÿ˜ + } + + @Test + fun `sanitizeEmoji returns null for emoji plus text`() = withEmojiSource { + assertNull(MemberLabel.sanitizeEmoji("\uD83D\uDE0Dhi")) + } + + @Test + fun `sanitizeEmoji returns null for null input`() = withEmojiSource { + assertNull(MemberLabel.sanitizeEmoji(null)) + } + + @Test + fun `sanitizeEmoji returns null for empty string`() = withEmojiSource { + assertNull(MemberLabel.sanitizeEmoji("")) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelSanitizationTest.kt b/app/src/test/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelSanitizationTest.kt new file mode 100644 index 0000000000..67b78579c8 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelSanitizationTest.kt @@ -0,0 +1,177 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.groups.memberlabel + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.signal.core.util.StringUtil + +class MemberLabelSanitizationTest { + @Test + fun `sanitizeLabelText trims leading and trailing whitespace`() { + assertEquals("hello", MemberLabel.sanitizeLabelText(" hello ")) + } + + @Test + fun `sanitizeLabelText replaces newline with space`() { + assertEquals("hello world", MemberLabel.sanitizeLabelText("hello\nworld")) + } + + @Test + fun `sanitizeLabelText replaces carriage return with space`() { + assertEquals("hello world", MemberLabel.sanitizeLabelText("hello\rworld")) + } + + @Test + fun `sanitizeLabelText replaces carriage return newline with space`() { + assertEquals("hello world", MemberLabel.sanitizeLabelText("hello\r\nworld")) + } + + @Test + fun `sanitizeLabelText replaces tab with space`() { + assertEquals("hello world", MemberLabel.sanitizeLabelText("hello\tworld")) + } + + @Test + fun `sanitizeLabelText collapses multiple spaces into one`() { + assertEquals("hello world", MemberLabel.sanitizeLabelText("hello world")) + } + + @Test + fun `sanitizeLabelText collapses mixed whitespace into single space`() { + assertEquals("hello world", MemberLabel.sanitizeLabelText("hello \n\r\t world")) + } + + @Test + fun `sanitizeLabelText collapses all-whitespace string`() { + assertEquals("", MemberLabel.sanitizeLabelText(" \n\r\t ")) + } + + @Test + fun `sanitizeLabelText preserves normal text`() { + assertEquals("hello world", MemberLabel.sanitizeLabelText("hello world")) + } + + @Test + fun `sanitizeLabelText trims leading and trailing unicode whitespace characters`() { + assertEquals("hello", MemberLabel.sanitizeLabelText("\u200Ehello\u200F")) + } + + @Test + fun `sanitizeLabelText does not truncate short text`() { + assertEquals("hello", MemberLabel.sanitizeLabelText("hello")) + } + + @Test + fun `sanitizeLabelText truncates to 24 graphemes`() { + val input = "A".repeat(30) + val result = MemberLabel.sanitizeLabelText(input) + assertEquals("A".repeat(24), result) + } + + @Test + fun `sanitizeLabelText counts emoji as single grapheme`() { + val input = "\uD83C\uDF89".repeat(30) // ๐ŸŽ‰ + val result = MemberLabel.sanitizeLabelText(input) + assertEquals("\uD83C\uDF89".repeat(24), result) + } + + @Test + fun `sanitizeLabelText handles mix of ascii and emoji`() { + val input = "A".repeat(20) + "\uD83C\uDF89".repeat(10) + val result = MemberLabel.sanitizeLabelText(input) + assertEquals("A".repeat(20) + "\uD83C\uDF89".repeat(4), result) + } + + @Test + fun `sanitizeLabelText enforces byte limit`() { + val fourByteEmoji = "\uD83C\uDF89" // ๐ŸŽ‰ is 4 bytes in UTF-8 + val input = fourByteEmoji.repeat(24) + val result = MemberLabel.sanitizeLabelText(input) + assertTrue(result.toByteArray(Charsets.UTF_8).size <= MemberLabel.MAX_LABEL_BYTES) + } + + @Test + fun `sanitizeLabelText does not exceed byte limit with large graphemes`() { + val familyEmoji = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66" // ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ is 25 bytes in UTF-8 + val input = familyEmoji.repeat(10) + val result = MemberLabel.sanitizeLabelText(input) + assertTrue(result.toByteArray(Charsets.UTF_8).size <= MemberLabel.MAX_LABEL_BYTES) + } + + @Test + fun `truncateLabelText truncates to 24 graphemes`() { + val input = "A".repeat(30) + assertEquals("A".repeat(24), MemberLabel.truncateLabelText(input)) + } + + @Test + fun `truncateLabelText preserves trailing space`() { + assertEquals("hello ", MemberLabel.truncateLabelText("hello ")) + } + + @Test + fun `truncateLabelText preserves leading space`() { + assertEquals(" hello", MemberLabel.truncateLabelText(" hello")) + } + + @Test + fun `truncateLabelText preserves multiple spaces between words`() { + assertEquals("hello world", MemberLabel.truncateLabelText("hello world")) + } + + @Test + fun `truncateLabelText enforces byte limit`() { + val fourByteEmoji = "\uD83C\uDF89" // ๐ŸŽ‰ = 4 bytes + val input = fourByteEmoji.repeat(30) + val result = MemberLabel.truncateLabelText(input) + assertTrue(result.toByteArray(Charsets.UTF_8).size <= MemberLabel.MAX_LABEL_BYTES) + assertEquals(fourByteEmoji.repeat(24), result) + } + + @Test + fun `displayText wraps non-ascii label text with BiDi isolation`() { + val arabicText = "ู…ุฑุญุจุง ุจุงู„ุนุงู„ู…" + assertEquals("\u2068$arabicText\u2069", MemberLabel(emoji = null, text = arabicText).displayText) + } + + @Test + fun `displayText does not wrap ascii-only label text`() { + val ascii = "Vet Coordinator" + assertEquals(ascii, MemberLabel(emoji = null, text = ascii).displayText) + } + + @Test + fun `displayText balances unmatched opening BiDi character`() { + val unbalanced = "hello\u2067world" + val result = MemberLabel(emoji = null, text = unbalanced).displayText + assertTrue(result.startsWith("\u2068")) + assertTrue(result.endsWith("\u2069")) + } + + @Test + fun `trimToFit does not truncate when within limit`() { + assertEquals("hello", StringUtil.trimToFit("hello", 10)) + } + + @Test + fun `trimToFit truncates ascii to byte limit`() { + val input = "A".repeat(100) + val result = StringUtil.trimToFit(input, 48) + assertEquals(48, result.toByteArray(Charsets.UTF_8).size) + assertEquals("A".repeat(48), result) + } + + @Test + fun `trimToFit does not split multi-byte graphemes`() { + val emoji = "\uD83C\uDF89" // ๐ŸŽ‰ = 4 bytes + val input = emoji.repeat(15) + val result = StringUtil.trimToFit(input, MemberLabel.MAX_EMOJI_BYTES) + assertTrue(result.toByteArray(Charsets.UTF_8).size <= MemberLabel.MAX_EMOJI_BYTES) + assertEquals(emoji.repeat(12), result) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModelTest.kt index 33b74480ef..e4ae3c8977 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/memberlabel/MemberLabelViewModelTest.kt @@ -11,6 +11,7 @@ import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before @@ -229,4 +230,88 @@ class MemberLabelViewModelTest { memberLabelRepo.setLabel(groupId, MemberLabel(text = "", emoji = null)) } } + + @Test + fun `onLabelTextChanged counts emoji as single grapheme`() = runTest(testDispatcher) { + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null + + val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val emoji = "\uD83C\uDF89" // ๐ŸŽ‰ + viewModel.onLabelTextChanged(emoji.repeat(30)) + + assertEquals(emoji.repeat(24), viewModel.uiState.value.labelText) + } + + @Test + fun `remainingCharacters counts emoji as single grapheme`() = runTest(testDispatcher) { + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null + + val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val emoji = "\uD83C\uDF89" // ๐ŸŽ‰ + viewModel.onLabelTextChanged(emoji.repeat(10)) + + assertEquals(14, viewModel.uiState.value.remainingCharacters) + } + + @Test + fun `remainingCharacters counts mixed ascii and emoji correctly`() = runTest(testDispatcher) { + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null + + val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + viewModel.onLabelTextChanged("Hello \uD83C\uDF89") // "Hello ๐ŸŽ‰" = 7 graphemes + + assertEquals(17, viewModel.uiState.value.remainingCharacters) + } + + @Test + fun `onLabelTextChanged does not truncate text within grapheme limit`() = runTest(testDispatcher) { + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null + + val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + viewModel.onLabelTextChanged("Short label") + + assertEquals("Short label", viewModel.uiState.value.labelText) + } + + @Test + fun `onLabelTextChanged truncates at exactly 24 graphemes with emoji`() = runTest(testDispatcher) { + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns null + + val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + val input = "A".repeat(23) + "\uD83C\uDF89\uD83C\uDF89" // 25 graphemes + viewModel.onLabelTextChanged(input) + + val expected = "A".repeat(23) + "\uD83C\uDF89" // 24 graphemes + assertEquals(expected, viewModel.uiState.value.labelText) + } + + @Test + fun `isSaveEnabled returns false when the only change is trailing whitespace`() = runTest(testDispatcher) { + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") + + val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + viewModel.onLabelTextChanged("Original ") + + assertFalse(viewModel.uiState.value.isSaveEnabled) + } + + @Test + fun `isSaveEnabled returns false when the only change is leading whitespace`() = runTest(testDispatcher) { + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") + + val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + viewModel.onLabelTextChanged(" Original") + + assertFalse(viewModel.uiState.value.isSaveEnabled) + } + + @Test + fun `isSaveEnabled returns true when text differs beyond whitespace`() = runTest(testDispatcher) { + coEvery { memberLabelRepo.getLabel(groupId, any()) } returns MemberLabel(emoji = null, text = "Original") + + val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId) + viewModel.onLabelTextChanged(" Modified ") + + assertTrue(viewModel.uiState.value.isSaveEnabled) + } }