Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-13 18:27:43 +01:00
parent 9f6d8cb6b5
commit 42fb506a01
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
}
}