From 42fb506a0177184863e7e5c256b06426920f3435 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 13 May 2026 18:27:43 +0100 Subject: [PATCH] Updated on 2026-08-14 --- .../navigation/email/AndroidEmailSender.kt | 10 +- .../navigation/email/EmailMessageTruncator.kt | 35 +++++++ .../navigation/email/EmailSenderModule.kt | 5 +- .../email/EmailMessageTruncatorTest.kt | 98 +++++++++++++++++++ .../features/details/model/DetailsModel.kt | 38 ++++--- 5 files changed, 169 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/core/navigation/email/EmailMessageTruncator.kt create mode 100644 app/src/test/kotlin/com/tangem/tap/core/navigation/email/EmailMessageTruncatorTest.kt diff --git a/app/src/main/java/com/tangem/tap/core/navigation/email/AndroidEmailSender.kt b/app/src/main/java/com/tangem/tap/core/navigation/email/AndroidEmailSender.kt index e0aea6d0e0..a8b0bd68d8 100644 --- a/app/src/main/java/com/tangem/tap/core/navigation/email/AndroidEmailSender.kt +++ b/app/src/main/java/com/tangem/tap/core/navigation/email/AndroidEmailSender.kt @@ -1,11 +1,11 @@ package com.tangem.tap.core.navigation.email import android.content.Intent -import android.net.Uri import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ShareCompat import androidx.core.content.ContextCompat import androidx.core.content.FileProvider +import androidx.core.net.toUri import com.tangem.core.navigation.email.EmailSender import com.tangem.tap.foregroundActivityObserver import com.tangem.utils.logging.TangemLogger @@ -15,7 +15,9 @@ import com.tangem.utils.logging.TangemLogger * [REDACTED_AUTHOR] */ -internal class AndroidEmailSender : EmailSender { +internal class AndroidEmailSender( + private val messageTruncator: EmailMessageTruncator, +) : EmailSender { override fun send(email: EmailSender.Email, onFail: ((Exception) -> Unit)?) { val activity = foregroundActivityObserver.foregroundActivity @@ -26,7 +28,7 @@ internal class AndroidEmailSender : EmailSender { } val originalIntent = createEmailShareIntent(activity, email) - val emailFilterIntent = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:")) + val emailFilterIntent = Intent(Intent.ACTION_SENDTO, "mailto:".toUri()) val packageManager = activity.packageManager val originalIntentResults = packageManager.queryIntentActivities(originalIntent, 0) @@ -59,7 +61,7 @@ internal class AndroidEmailSender : EmailSender { .setType("message/rfc822") .setEmailTo(arrayOf(email.address)) .setSubject(email.subject) - .setText(email.message) + .setText(messageTruncator.truncate(email.message)) email.attachment?.let { file -> builder.setStream( diff --git a/app/src/main/java/com/tangem/tap/core/navigation/email/EmailMessageTruncator.kt b/app/src/main/java/com/tangem/tap/core/navigation/email/EmailMessageTruncator.kt new file mode 100644 index 0000000000..f7a4bb281d --- /dev/null +++ b/app/src/main/java/com/tangem/tap/core/navigation/email/EmailMessageTruncator.kt @@ -0,0 +1,35 @@ +package com.tangem.tap.core.navigation.email + +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction + +/** + * Truncates an email body so the resulting Intent fits inside the per-process Binder buffer (1 MB). + * + * The chooser fans the Intent out to every installed email client (with extras duplicated per target), + * so the body must be kept well below the raw 1 MB ceiling. + */ +internal class EmailMessageTruncator { + + fun truncate(message: String): String { + val bytes = message.toByteArray(Charsets.UTF_8) + if (bytes.size <= MAX_MESSAGE_BYTES) return message + + val suffix = TRUNCATION_SUFFIX_TEMPLATE.format(bytes.size) + val suffixBytes = suffix.toByteArray(Charsets.UTF_8).size + val cutSize = MAX_MESSAGE_BYTES - suffixBytes + + // Drop a partial UTF-8 sequence at the cut boundary rather than replacing it with U+FFFD + // (which is 3 bytes in UTF-8 and would push the result over the cap). + val decoder = Charsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.IGNORE) + val head = decoder.decode(ByteBuffer.wrap(bytes, 0, cutSize)).toString() + return head + suffix + } + + private companion object { + // Chooser duplicates EXTRA_TEXT once per target email app (EXTRA_INITIAL_INTENTS), + // so parcel ā‰ˆ N Ɨ body. 20 KB clears the 1 MB Binder limit for up to ~30 mail clients. + const val MAX_MESSAGE_BYTES = 20_000 + const val TRUNCATION_SUFFIX_TEMPLATE = "\n\n…[truncated, original %d bytes]" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/core/navigation/email/EmailSenderModule.kt b/app/src/main/java/com/tangem/tap/di/core/navigation/email/EmailSenderModule.kt index b986cfe46d..0aacfd2742 100644 --- a/app/src/main/java/com/tangem/tap/di/core/navigation/email/EmailSenderModule.kt +++ b/app/src/main/java/com/tangem/tap/di/core/navigation/email/EmailSenderModule.kt @@ -2,6 +2,7 @@ package com.tangem.tap.di.core.navigation.email import com.tangem.core.navigation.email.EmailSender import com.tangem.tap.core.navigation.email.AndroidEmailSender +import com.tangem.tap.core.navigation.email.EmailMessageTruncator import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -14,5 +15,7 @@ internal object EmailSenderModule { @Provides @Singleton - fun provideEmailSender(): EmailSender = AndroidEmailSender() + fun provideEmailSender(): EmailSender = AndroidEmailSender( + messageTruncator = EmailMessageTruncator(), + ) } \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/core/navigation/email/EmailMessageTruncatorTest.kt b/app/src/test/kotlin/com/tangem/tap/core/navigation/email/EmailMessageTruncatorTest.kt new file mode 100644 index 0000000000..07cd8a2d33 --- /dev/null +++ b/app/src/test/kotlin/com/tangem/tap/core/navigation/email/EmailMessageTruncatorTest.kt @@ -0,0 +1,98 @@ +package com.tangem.tap.core.navigation.email + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class EmailMessageTruncatorTest { + + private val truncator = EmailMessageTruncator() + + @Test + fun `empty message returned as-is`() { + val result = truncator.truncate("") + + assertThat(result).isEqualTo("") + } + + @Test + fun `message under cap returned unchanged`() { + val message = "small message" + + val result = truncator.truncate(message) + + assertThat(result).isEqualTo(message) + } + + @Test + fun `message exactly at cap returned unchanged`() { + val message = "a".repeat(MAX_MESSAGE_BYTES) + + val result = truncator.truncate(message) + + assertThat(result).isEqualTo(message) + } + + @Test + fun `message over cap is truncated to fit within cap in bytes`() { + val message = "a".repeat(MAX_MESSAGE_BYTES + 1_000) + + val result = truncator.truncate(message) + + assertThat(result.toByteArray(Charsets.UTF_8).size).isAtMost(MAX_MESSAGE_BYTES) + } + + @Test + fun `truncated message preserves the head of the original`() { + val head = "HEAD_MARKER_" + "x".repeat(100) + val tail = "y".repeat(MAX_MESSAGE_BYTES) + val message = head + tail + + val result = truncator.truncate(message) + + assertThat(result).startsWith(head) + } + + @Test + fun `truncated message ends with the truncation suffix`() { + val message = "a".repeat(MAX_MESSAGE_BYTES + 1_000) + + val result = truncator.truncate(message) + + assertThat(result).contains("[truncated, original ${message.length} bytes]") + } + + @Test + fun `truncation suffix reports original byte length not character length`() { + // Each emoji is 4 bytes in UTF-8. + val emoji = "šŸ˜€" // šŸ˜€ + val message = emoji.repeat(MAX_MESSAGE_BYTES / 4 + 10) + val originalBytes = message.toByteArray(Charsets.UTF_8).size + + val result = truncator.truncate(message) + + assertThat(result).contains("[truncated, original $originalBytes bytes]") + } + + @Test + fun `multi-byte UTF-8 boundary stays within cap and produces valid output`() { + // Build a message where the cap falls inside a multi-byte char. + val emoji = "šŸ˜€" // šŸ˜€, 4 bytes in UTF-8 + val message = emoji.repeat(MAX_MESSAGE_BYTES) // Way over cap. + + val result = truncator.truncate(message) + + // Partial trailing char is dropped (not replaced with U+FFFD which is 3 bytes and would + // push the result over the cap), so the result must stay within the cap and survive a + // UTF-8 round-trip. + val roundTripped = String(result.toByteArray(Charsets.UTF_8), Charsets.UTF_8) + assertThat(roundTripped).isEqualTo(result) + assertThat(result.toByteArray(Charsets.UTF_8).size).isAtMost(MAX_MESSAGE_BYTES) + } + + private companion object { + // Mirror the constant inside EmailMessageTruncator. Keep in sync if it changes there. + const val MAX_MESSAGE_BYTES = 20_000 + } +} \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt index 840d7776a1..7127fd8a3b 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt @@ -124,18 +124,29 @@ internal class DetailsModel @Inject constructor( val metaInfo = getWalletMetaInfoUseCase(selectedUserWallet.walletId).getOrNull() ?: return@launch val visaCustomerId = getTangemPayCustomerIdUseCase(selectedUserWallet.walletId).getOrNull() + val coldVisaPredicate = { userWallet: UserWallet -> + userWallet is UserWallet.Cold && userWallet.scanResponse.card.isVisa && !visaCustomerId.isNullOrEmpty() + } + val hotWalletOrNotVisaPredicate = { userWallet: UserWallet -> + userWallet !is UserWallet.Cold || userWallet.scanResponse.card.isVisa.not() + } val feedbackType = when { - userWallets.all { - it is UserWallet.Cold && it.scanResponse.card.isVisa && !visaCustomerId.isNullOrEmpty() - } -> + userWallets.all(coldVisaPredicate) -> { FeedbackEmailType.Visa.DirectUserRequest( walletMetaInfo = metaInfo, customerId = requireNotNull(visaCustomerId), ) - userWallets.all { it !is UserWallet.Cold || it.scanResponse.card.isVisa.not() } -> - FeedbackEmailType.DirectUserRequest(metaInfo) + } + userWallets.all(hotWalletOrNotVisaPredicate) -> { + FeedbackEmailType.DirectUserRequest( + walletMetaInfo = metaInfo, + ) + } else -> { - showFeedbackEmailTypeOptionBS(selectedWalletMetaInfo = metaInfo, visaCustomerId = visaCustomerId) + showFeedbackEmailTypeOptionBS( + selectedWalletMetaInfo = metaInfo, + visaCustomerId = visaCustomerId, + ) return@launch } } @@ -161,8 +172,9 @@ internal class DetailsModel @Inject constructor( onDismissRequest = { state.update { current.copy( - selectFeedbackEmailTypeBSConfig = - current.selectFeedbackEmailTypeBSConfig.copy(isShown = false), + selectFeedbackEmailTypeBSConfig = current.selectFeedbackEmailTypeBSConfig.copy( + isShown = false, + ), ) } }, @@ -174,10 +186,12 @@ internal class DetailsModel @Inject constructor( visaCustomerId = visaCustomerId, ) - state.update { - it.copy( - selectFeedbackEmailTypeBSConfig = - it.selectFeedbackEmailTypeBSConfig.copy(isShown = false), + state.update { details -> + val hiddenConfig = details.selectFeedbackEmailTypeBSConfig.copy( + isShown = false, + ) + details.copy( + selectFeedbackEmailTypeBSConfig = hiddenConfig, ) } },