Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-15 10:11:34 +01:00
commit 8a749438bf
5 changed files with 169 additions and 17 deletions

View file

@ -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(

View file

@ -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]"
}
}

View file

@ -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(),
)
}

View file

@ -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
}
}

View file

@ -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,
)
}
},