Updated on 2026-08-14
This commit is contained in:
parent
9f6d8cb6b5
commit
42fb506a01
5 changed files with 169 additions and 17 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue