mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-02 08:23:00 +01:00
Enforce member label emoji and text constraints.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -381,7 +381,7 @@ private fun MemberLabelRow(
|
||||
}
|
||||
|
||||
Text(
|
||||
text = memberLabel.text,
|
||||
text = memberLabel.displayText,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
|
||||
@@ -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(""))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<RecipientId>()) } 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<RecipientId>()) } 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<RecipientId>()) } 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<RecipientId>()) } 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<RecipientId>()) } 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<RecipientId>()) } 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<RecipientId>()) } 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<RecipientId>()) } returns MemberLabel(emoji = null, text = "Original")
|
||||
|
||||
val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
|
||||
viewModel.onLabelTextChanged(" Modified ")
|
||||
|
||||
assertTrue(viewModel.uiState.value.isSaveEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user