diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 4367c0b661..a1176799f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.kt index 4f5685d886..aad6f4b57e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.kt @@ -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.") diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java index 6c09b9dc45..18ed469ded 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java @@ -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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java index 82e69d2f97..9e2abaedd1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java @@ -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 deliver(OutgoingMessage message, @Nullable MessageRecord originalEditedMessage, @NonNull Recipient groupRecipient, @NonNull List 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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt index 48a144444d..7057ef26a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java index 03cbb2087d..09146a200c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java @@ -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 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 || diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/composer/StoryReplyComposer.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/composer/StoryReplyComposer.kt index d25e39d162..95d42dd336 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/composer/StoryReplyComposer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/composer/StoryReplyComposer.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MessageUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/MessageUtil.kt index bd690d3c9f..684d4d5ba7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MessageUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MessageUtil.kt @@ -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() diff --git a/app/src/main/res/layout/stories_reply_to_story_composer_content.xml b/app/src/main/res/layout/stories_reply_to_story_composer_content.xml index ed5a2da37f..bb9e536acf 100644 --- a/app/src/main/res/layout/stories_reply_to_story_composer_content.xml +++ b/app/src/main/res/layout/stories_reply_to_story_composer_content.xml @@ -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" diff --git a/app/src/main/res/layout/v2_media_add_message_dialog_fragment_content.xml b/app/src/main/res/layout/v2_media_add_message_dialog_fragment_content.xml index 170d04c45c..0c21ec5c5b 100644 --- a/app/src/main/res/layout/v2_media_add_message_dialog_fragment_content.xml +++ b/app/src/main/res/layout/v2_media_add_message_dialog_fragment_content.xml @@ -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" diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 5c5741d6e8..b25f87b53b 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -205,7 +205,6 @@ 2dp @null 5 - 65536 @color/signal_text_primary @color/signal_colorOnSurfaceVariant sentences diff --git a/core-util/src/main/java/org/signal/core/util/ByteLimitInputFilter.kt b/core-util/src/main/java/org/signal/core/util/ByteLimitInputFilter.kt new file mode 100644 index 0000000000..40928d60d7 --- /dev/null +++ b/core-util/src/main/java/org/signal/core/util/ByteLimitInputFilter.kt @@ -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) + } +} diff --git a/core-util/src/main/java/org/signal/core/util/CharSequenceExtensions.kt b/core-util/src/main/java/org/signal/core/util/CharSequenceExtensions.kt new file mode 100644 index 0000000000..003bf46eee --- /dev/null +++ b/core-util/src/main/java/org/signal/core/util/CharSequenceExtensions.kt @@ -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 +} diff --git a/core-util/src/test/java/org/signal/core/util/ByteLimitInputFilterTest.kt b/core-util/src/test/java/org/signal/core/util/ByteLimitInputFilterTest.kt new file mode 100644 index 0000000000..740f824452 --- /dev/null +++ b/core-util/src/test/java/org/signal/core/util/ByteLimitInputFilterTest.kt @@ -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) + } +}