Truncate message length based on utf8-byte size.

This commit is contained in:
Greyson Parrelli
2025-07-18 16:27:41 -04:00
committed by GitHub
parent 84c6719d03
commit 1cef53d82e
14 changed files with 500 additions and 9 deletions

View File

@@ -92,6 +92,7 @@ import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.util.ByteLimitInputFilter
import org.signal.core.util.PendingIntentFlags
import org.signal.core.util.Result
import org.signal.core.util.ThreadUtil
@@ -319,6 +320,7 @@ import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.MessageConstraintsUtil
import org.thoughtcrime.securesms.util.MessageConstraintsUtil.getEditMessageThresholdHours
import org.thoughtcrime.securesms.util.MessageConstraintsUtil.isValidEditMessageSend
import org.thoughtcrime.securesms.util.MessageUtil
import org.thoughtcrime.securesms.util.PlayStoreUtil
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.SignalLocalMetrics
@@ -405,7 +407,7 @@ class ConversationFragment :
}
private val disposables = LifecycleDisposable()
private val binding by ViewBinderDelegate(V2ConversationFragmentBinding::bind) { _binding ->
private val binding by ViewBinderDelegate(bindingFactory = V2ConversationFragmentBinding::bind, onBindingWillBeDestroyed = { _binding ->
_binding.conversationInputPanel.embeddedTextEditor.apply {
setOnEditorActionListener(null)
setCursorPositionChangedListener(null)
@@ -428,7 +430,7 @@ class ConversationFragment :
_binding.conversationItemRecycler.adapter = null
textDraftSaveDebouncer.clear()
}
})
private val viewModel: ConversationViewModel by viewModel {
ConversationViewModel(
@@ -1013,6 +1015,7 @@ class ConversationFragment :
setStylingChangedListener(composeTextEventsListener)
setOnClickListener(composeTextEventsListener)
onFocusChangeListener = composeTextEventsListener
filters += ByteLimitInputFilter(MessageUtil.MAX_TOTAL_BODY_SIZE_BYTES)
}
sendButton.apply {

View File

@@ -31,6 +31,9 @@ import org.thoughtcrime.securesms.net.NotPushRegisteredException
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.AttachmentProgressService
import org.thoughtcrime.securesms.transport.UndeliverableMessageException
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.MessageUtil
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
@@ -137,6 +140,10 @@ class AttachmentUploadJob private constructor(
val databaseAttachment = SignalDatabase.attachments.getAttachment(attachmentId) ?: throw InvalidAttachmentException("Cannot find the specified attachment.")
if (MediaUtil.isLongTextType(databaseAttachment.contentType) && databaseAttachment.size > MessageUtil.MAX_TOTAL_BODY_SIZE_BYTES) {
throw UndeliverableMessageException("Long text attachment is too long! Max size: ${MessageUtil.MAX_TOTAL_BODY_SIZE_BYTES} bytes, Actual size: ${databaseAttachment.size} bytes.")
}
val timeSinceUpload = System.currentTimeMillis() - databaseAttachment.uploadTimestamp
if (timeSinceUpload < UPLOAD_REUSE_THRESHOLD && !TextUtils.isEmpty(databaseAttachment.remoteLocation)) {
Log.i(TAG, "We can re-use an already-uploaded file. It was uploaded $timeSinceUpload ms (${timeSinceUpload.milliseconds.inRoundedDays()} days) ago. Skipping.")

View File

@@ -32,6 +32,7 @@ import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.transport.RetryLaterException;
import org.thoughtcrime.securesms.transport.UndeliverableMessageException;
import org.thoughtcrime.securesms.util.MessageUtil;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
@@ -60,6 +61,8 @@ import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import okio.Utf8;
public class IndividualSendJob extends PushSendJob {
public static final String KEY = "PushMediaSendJob";
@@ -251,6 +254,10 @@ public class IndividualSendJob extends PushSendJob {
throw new UndeliverableMessageException("No destination address.");
}
if (Utf8.size(message.getBody()) > MessageUtil.MAX_INLINE_BODY_SIZE_BYTES) {
throw new UndeliverableMessageException("The total body size was greater than our limit of " + MessageUtil.MAX_INLINE_BODY_SIZE_BYTES + " bytes.");
}
try {
rotateSenderCertificateIfNecessary();

View File

@@ -44,6 +44,7 @@ import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.transport.RetryLaterException;
import org.thoughtcrime.securesms.transport.UndeliverableMessageException;
import org.thoughtcrime.securesms.util.GroupUtil;
import org.thoughtcrime.securesms.util.MessageUtil;
import org.thoughtcrime.securesms.util.RecipientAccessList;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.Util;
@@ -73,6 +74,7 @@ import java.util.Set;
import java.util.concurrent.TimeUnit;
import okio.ByteString;
import okio.Utf8;
public final class PushGroupSendJob extends PushSendJob {
@@ -266,6 +268,10 @@ public final class PushGroupSendJob extends PushSendJob {
private List<SendMessageResult> deliver(OutgoingMessage message, @Nullable MessageRecord originalEditedMessage, @NonNull Recipient groupRecipient, @NonNull List<Recipient> destinations)
throws IOException, UntrustedIdentityException, UndeliverableMessageException
{
if (Utf8.size(message.getBody()) >= MessageUtil.MAX_INLINE_BODY_SIZE_BYTES) {
throw new UndeliverableMessageException("The total body size was greater than our limit of " + MessageUtil.MAX_INLINE_BODY_SIZE_BYTES + " bytes.");
}
try {
rotateSenderCertificateIfNecessary();

View File

@@ -14,6 +14,7 @@ import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import org.signal.core.util.ByteLimitInputFilter
import org.signal.core.util.EditTextUtil
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout
@@ -38,6 +39,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.MessageUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.views.Stub
import org.thoughtcrime.securesms.util.visible
@@ -92,6 +94,7 @@ class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_a
binding.content.addAMessageInput.setText(requireArguments().getCharSequence(ARG_INITIAL_TEXT))
binding.content.addAMessageInput.addTextChangedListener { viewModel.setMessage(it) }
binding.content.addAMessageInput.filters += ByteLimitInputFilter(MessageUtil.MAX_TOTAL_BODY_SIZE_BYTES)
binding.content.emojiToggle.setOnClickListener { onEmojiToggleClicked() }
if (requireArguments().getBoolean(ARG_INITIAL_EMOJI_TOGGLE) && view is KeyboardAwareLinearLayout) {

View File

@@ -51,7 +51,6 @@ import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.MessageUtil;
import org.thoughtcrime.securesms.util.Util;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
@@ -116,7 +115,7 @@ public final class MultiShareSender {
List<Contact> contacts = multiShareArgs.getSharedContacts();
SlideDeck slideDeck = new SlideDeck(primarySlideDeck);
boolean needsSplit = message != null && Utf8.size(message) > MessageUtil.MAX_MESSAGE_SIZE_BYTES;
boolean needsSplit = message != null && Utf8.size(message) > MessageUtil.MAX_INLINE_BODY_SIZE_BYTES;
boolean hasMmsMedia = !multiShareArgs.getMedia().isEmpty() ||
(multiShareArgs.getDataUri() != null && multiShareArgs.getDataUri() != Uri.EMPTY) ||
multiShareArgs.getStickerLocator() != null ||

View File

@@ -16,6 +16,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.transition.AutoTransition
import androidx.transition.TransitionManager
import org.signal.core.util.ByteLimitInputFilter
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ComposeText
@@ -34,6 +35,7 @@ import org.thoughtcrime.securesms.keyboard.emoji.toMappingModels
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.MessageUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
@@ -86,6 +88,7 @@ class StoryReplyComposer @JvmOverloads constructor(
else -> false
}
}
input.filters += ByteLimitInputFilter(MessageUtil.MAX_TOTAL_BODY_SIZE_BYTES)
anyReactionView.setOnClickListener {
callback?.onPickAnyReactionClicked()

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.util
import android.content.Context
import org.signal.core.util.kibiBytes
import org.signal.core.util.splitByByteLength
import org.thoughtcrime.securesms.mms.TextSlide
import org.thoughtcrime.securesms.providers.BlobProvider
@@ -10,7 +11,13 @@ import java.util.Locale
import java.util.Optional
object MessageUtil {
const val MAX_MESSAGE_SIZE_BYTES: Int = 2000 // Technically 2048, but we'll play it a little safe
/** The maximum size of an inlined text body we'll allow in a proto. Anything larger than this will need to be a long-text attachment. */
@JvmField
val MAX_INLINE_BODY_SIZE_BYTES: Int = 2.kibiBytes.bytes.toInt()
/** The maximum total message size we'll allow ourselves to send, even as a long text attachment. */
@JvmField
val MAX_TOTAL_BODY_SIZE_BYTES = 64.kibiBytes.bytes.toInt()
/**
* @return If the message is longer than the allowed text size, this will return trimmed text with
@@ -18,7 +25,7 @@ object MessageUtil {
*/
@JvmStatic
fun getSplitMessage(context: Context, rawText: String): SplitResult {
val (trimmed, remainder) = rawText.splitByByteLength(MAX_MESSAGE_SIZE_BYTES)
val (trimmed, remainder) = rawText.splitByByteLength(MAX_INLINE_BODY_SIZE_BYTES)
return if (remainder != null) {
val textData = rawText.toByteArray()

View File

@@ -81,7 +81,6 @@
android:layout_gravity="center_vertical"
android:imeOptions="flagNoEnterAction|actionSend"
android:inputType="textAutoCorrect|textCapSentences|textMultiLine"
android:maxLength="65536"
android:maxLines="3"
android:paddingBottom="2dp"
android:textAppearance="@style/Signal.Text.Body"

View File

@@ -56,7 +56,6 @@
android:hint="@string/MediaReviewFragment__add_a_message"
android:imeOptions="flagNoEnterAction"
android:inputType="textAutoCorrect|textCapSentences|textMultiLine"
android:maxLength="65536"
android:maxLines="3"
android:minHeight="@dimen/conversation_compose_height"
android:paddingEnd="10dp"

View File

@@ -205,7 +205,6 @@
<item name="android:padding">2dp</item>
<item name="android:background">@null</item>
<item name="android:maxLines">5</item>
<item name="android:maxLength">65536</item>
<item name="android:textColor">@color/signal_text_primary</item>
<item name="android:textColorHint">@color/signal_colorOnSurfaceVariant</item>
<item name="android:capitalize">sentences</item>

View File

@@ -0,0 +1,76 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util
import android.text.InputFilter
import android.text.Spanned
/**
* An [InputFilter] that prevents the target text from growing beyond [byteLimit] bytes when using UTF-8 encoding.
*/
class ByteLimitInputFilter(private val byteLimit: Int) : InputFilter {
override fun filter(source: CharSequence?, start: Int, end: Int, dest: Spanned?, dstart: Int, dend: Int): CharSequence? {
if (source == null || dest == null) {
return null
}
val insertText = source.subSequence(start, end)
val beforeText = dest.subSequence(0, dstart)
val afterText = dest.subSequence(dend, dest.length)
val insertByteLength = insertText.utf8Size()
val beforeByteLength = beforeText.utf8Size()
val afterByteLength = afterText.utf8Size()
val resultByteSize = beforeByteLength + insertByteLength + afterByteLength
if (resultByteSize <= byteLimit) {
return null
}
val availableBytes = byteLimit - beforeByteLength - afterByteLength
if (availableBytes <= 0) {
return ""
}
return truncateToByteLimit(insertText, availableBytes)
}
private fun truncateToByteLimit(text: CharSequence, maxBytes: Int): CharSequence {
var byteCount = 0
var charIndex = 0
while (charIndex < text.length) {
val char = text[charIndex]
val charBytes = when {
char.code < 0x80 -> 1
char.code < 0x800 -> 2
char.isHighSurrogate() -> {
if (charIndex + 1 < text.length && text[charIndex + 1].isLowSurrogate()) {
4
} else {
3
}
}
char.isLowSurrogate() -> 3 // Treat orphaned low surrogate as 3 bytes
else -> 3
}
if (byteCount + charBytes > maxBytes) {
break
}
byteCount += charBytes
charIndex++
if (char.isHighSurrogate() && charIndex < text.length && text[charIndex].isLowSurrogate()) {
charIndex++
}
}
return text.subSequence(0, charIndex)
}
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util
/**
* A copy of [okio.utf8Size] that works on [CharSequence].
*/
fun CharSequence.utf8Size(): Int {
var result = 0
var i = 0
while (i < this.length) {
val c = this[i].code
if (c < 0x80) {
// A 7-bit character with 1 byte.
result++
i++
} else if (c < 0x800) {
// An 11-bit character with 2 bytes.
result += 2
i++
} else if (c < 0xd800 || c > 0xdfff) {
// A 16-bit character with 3 bytes.
result += 3
i++
} else {
val low = if (i + 1 < this.length) this[i + 1].code else 0
if (c > 0xdbff || low < 0xdc00 || low > 0xdfff) {
// A malformed surrogate, which yields '?'.
result++
i++
} else {
// A 21-bit character with 4 bytes.
result += 4
i += 2
}
}
}
return result
}

View File

@@ -0,0 +1,339 @@
package org.signal.core.util
import android.app.Application
import android.text.SpannedString
import android.widget.TextView
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isGreaterThan
import assertk.assertions.isLessThan
import assertk.assertions.isLessThanOrEqualTo
import assertk.assertions.isNull
import okio.utf8Size
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class ByteLimitInputFilterTest {
@Test
fun `filter - null source, returns null`() {
val filter = ByteLimitInputFilter(10)
val result = filter.filter(null, 0, 0, SpannedString(""), 0, 0)
assertThat(result).isNull()
}
@Test
fun `filter - null dest, returns null`() {
val filter = ByteLimitInputFilter(10)
val result = filter.filter("test", 0, 4, null, 0, 0)
assertThat(result).isNull()
}
@Test
fun `filter - within byte limit, returns null`() {
val filter = ByteLimitInputFilter(10)
val existingText = SpannedString("hi")
val insertText = "test"
val result = filter.testAppend(insertText, existingText)
assertThat(result).isNull()
}
@Test
fun `filter - exact byte limit, returns null`() {
val filter = ByteLimitInputFilter(6)
val dest = SpannedString("hi")
val insertText = "test"
val result = filter.testAppend(insertText, dest)
assertThat(result).isNull()
}
@Test
fun `filter - exceeds byte limit, returns truncated`() {
val filter = ByteLimitInputFilter(5)
val dest = SpannedString("hi")
val insertText = "test"
val result = filter.testAppend(insertText, dest)
assertThat(result.toString()).isEqualTo("tes")
}
@Test
fun `filter - no space available, returns empty`() {
val filter = ByteLimitInputFilter(2)
val dest = SpannedString("hi")
val insertText = "test"
val result = filter.testAppend(insertText, dest)
assertThat(result.toString()).isEqualTo("")
}
@Test
fun `filter - insert at beginning`() {
val filter = ByteLimitInputFilter(6)
val dest = SpannedString("hi")
val insertText = "test"
val result = filter.testPrepend(insertText, dest)
assertThat(result).isNull()
}
@Test
fun `filter - insert at end`() {
val filter = ByteLimitInputFilter(6)
val dest = SpannedString("hi")
val insertText = "test"
val result = filter.testAppend(insertText, dest)
assertThat(result).isNull()
}
@Test
fun `filter - replace text`() {
val filter = ByteLimitInputFilter(6)
val dest = SpannedString("hello")
val insertText = "test"
val result = filter.testReplaceRange(insertText, dest, 1, 4)
assertThat(result).isNull()
}
@Test
fun `filter - unicode characters`() {
val filter = ByteLimitInputFilter(9)
val dest = SpannedString("hi")
val insertText = "café"
val result = filter.testAppend(insertText, dest)
assertThat(result).isNull()
}
@Test
fun `filter - emoji characters`() {
val filter = ByteLimitInputFilter(6)
val dest = SpannedString("hi")
val insertText = "😀😁"
assertThat((insertText + dest).utf8Size()).isGreaterThan(6)
val result = filter.testAppend(insertText, dest)
assertThat(result.toString()).isEqualTo("😀")
}
@Test
fun `filter - mixed unicode and emoji`() {
val filter = ByteLimitInputFilter(15)
val dest = SpannedString("test")
val insertText = "café😀"
val result = filter.testAppend(insertText, dest)
assertThat(result).isNull()
}
@Test
fun `filter - partial source range`() {
val filter = ByteLimitInputFilter(5)
val dest = SpannedString("hi")
val source = "abcdef"
val result = filter.testPartialSource(source, 1, 4, dest, dest.length)
assertThat(result).isNull()
}
@Test
fun `filter - long text truncation`() {
val filter = ByteLimitInputFilter(10)
val dest = SpannedString("")
val longText = "this is a very long text that should be truncated"
val result = filter.testAppend(longText, dest)
assertThat(result.toString()).isEqualTo("this is a ")
}
@Test
fun `filter - ascii characters`() {
val filter = ByteLimitInputFilter(5)
val dest = SpannedString("")
val insertText = "hello"
val result = filter.testAppend(insertText, dest)
assertThat(result).isNull()
}
@Test
fun `filter - surrogate handling`() {
val filter = ByteLimitInputFilter(8)
val dest = SpannedString("hi")
val insertText = "🎉🎊"
val result = filter.testAppend(insertText, dest)
assertThat(result.toString()).isEqualTo("🎉")
}
@Test
fun `filter - empty source`() {
val filter = ByteLimitInputFilter(10)
val dest = SpannedString("test")
val insertText = ""
val result = filter.testInsertAt(insertText, dest, 2)
assertThat(result).isNull()
}
@Test
fun `filter - empty dest`() {
val filter = ByteLimitInputFilter(3)
val dest = SpannedString("")
val insertText = "test"
val result = filter.testAppend(insertText, dest)
assertThat(result.toString()).isEqualTo("tes")
}
@Test
fun `filter - unicode truncation`() {
val filter = ByteLimitInputFilter(4)
val dest = SpannedString("")
val insertText = "café"
val result = filter.testAppend(insertText, dest)
assertThat(result.toString()).isEqualTo("caf")
}
@Test
fun `filter - emoji truncation`() {
val filter = ByteLimitInputFilter(4)
val dest = SpannedString("")
val insertText = "😀a"
val result = filter.testAppend(insertText, dest)
assertThat(result.toString()).isEqualTo("😀")
}
@Test
fun `filter - insert at middle`() {
val filter = ByteLimitInputFilter(7)
val dest = SpannedString("hello")
val insertText = "XY"
val result = filter.testInsertAt(insertText, dest, 2)
assertThat(result).isNull()
}
@Test
fun `filter - insert at middle with truncation`() {
val filter = ByteLimitInputFilter(6)
val dest = SpannedString("hello")
val insertText = "XYZ"
val result = filter.testInsertAt(insertText, dest, 2)
assertThat(result.toString()).isEqualTo("X")
}
@Test
fun `textView integration - append within limit`() {
val textView = TextView(RuntimeEnvironment.getApplication())
textView.filters = arrayOf(ByteLimitInputFilter(10))
textView.setText("hi", TextView.BufferType.EDITABLE)
textView.append("test")
assertThat(textView.text.toString()).isEqualTo("hitest")
}
@Test
fun `textView integration - append exceeds limit`() {
val textView = TextView(RuntimeEnvironment.getApplication())
textView.filters = arrayOf(ByteLimitInputFilter(5))
textView.setText("hi", TextView.BufferType.EDITABLE)
textView.append("test")
assertThat(textView.text.toString()).isEqualTo("hites")
}
@Test
fun `textView integration - replace text with truncation`() {
val textView = TextView(RuntimeEnvironment.getApplication())
textView.filters = arrayOf(ByteLimitInputFilter(8))
textView.setText("hello", TextView.BufferType.EDITABLE)
val editable = textView.editableText
editable.replace(3, 5, "test")
assertThat(textView.text.toString()).isEqualTo("heltest")
}
@Test
fun `textView integration - emoji handling`() {
val textView = TextView(RuntimeEnvironment.getApplication())
textView.filters = arrayOf(ByteLimitInputFilter(10))
textView.setText("hi", TextView.BufferType.EDITABLE)
textView.append("😀😁")
assertThat(textView.text.toString().utf8Size()).isEqualTo(10)
}
@Test
fun `textView integration - unicode characters`() {
val textView = TextView(RuntimeEnvironment.getApplication())
textView.filters = arrayOf(ByteLimitInputFilter(10))
textView.setText("hi", TextView.BufferType.EDITABLE)
textView.append("café")
assertThat(textView.text.toString()).isEqualTo("hicafé")
}
@Test
fun `textView integration - set text directly`() {
val textView = TextView(RuntimeEnvironment.getApplication())
textView.filters = arrayOf(ByteLimitInputFilter(5))
textView.setText("this is a long text", TextView.BufferType.EDITABLE)
assertThat(textView.text.toString()).isEqualTo("this ")
}
@Test
fun `textView integration - fuzzing with mixed character types`() {
val textView = TextView(RuntimeEnvironment.getApplication())
val byteLimit = 100
textView.filters = arrayOf(ByteLimitInputFilter(byteLimit))
val asciiChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?"
val unicodeChars = "àáâãäåæçèéêëìíîïñòóôõöøùúûüýÿ"
val emojiChars = "😀😁😂😃😄😅😆😇😈😉😊😋😌😍😎😏😐😑😒😓😔😕😖😗😘😙😚😛😜😝😞😟😠😡😢😣😤😥😦😧😨😩😪😫😬😭😮😯😰😱😲😳😴😵😶😷😸😹😺😻😼😽😾😿🙀🙁🙂"
val japaneseChars = "あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをんアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン日本語漢字平仮名片仮名"
val allChars = asciiChars + unicodeChars + emojiChars + japaneseChars
repeat(100) { iteration ->
textView.setText("", TextView.BufferType.EDITABLE)
val targetLength = 150 + (iteration * 5)
val randomText = StringBuilder().apply {
repeat(targetLength) {
append(allChars.random())
}
}
textView.setText(randomText.toString(), TextView.BufferType.EDITABLE)
val finalText = textView.text.toString()
val actualByteSize = finalText.utf8Size()
assertThat(actualByteSize).isLessThanOrEqualTo((byteLimit).toLong())
if (randomText.toString().utf8Size() > byteLimit) {
assertThat(finalText.length).isLessThan(randomText.length)
}
}
}
private fun ByteLimitInputFilter.testAppend(insertText: String, dest: SpannedString): CharSequence? {
return this.filter(insertText, 0, insertText.length, dest, dest.length, dest.length)
}
private fun ByteLimitInputFilter.testPrepend(insertText: String, dest: SpannedString): CharSequence? {
return this.filter(insertText, 0, insertText.length, dest, 0, 0)
}
private fun ByteLimitInputFilter.testInsertAt(insertText: String, dest: SpannedString, position: Int): CharSequence? {
return this.filter(insertText, 0, insertText.length, dest, position, position)
}
private fun ByteLimitInputFilter.testReplaceRange(insertText: String, dest: SpannedString, startPos: Int, endPos: Int): CharSequence? {
return this.filter(insertText, 0, insertText.length, dest, startPos, endPos)
}
private fun ByteLimitInputFilter.testPartialSource(source: String, startPos: Int, endPos: Int, dest: SpannedString, insertPos: Int): CharSequence? {
return this.filter(source, startPos, endPos, dest, insertPos, insertPos)
}
}