diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpMsg.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpMsg.kt index 3b75367b9c..0cbe27de11 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpMsg.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpMsg.kt @@ -60,6 +60,7 @@ import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.jsoup.nodes.Element import org.jsoup.nodes.TextNode +import org.jsoup.parser.Parser import org.owasp.html.HtmlPolicyBuilder import org.pgpainless.decryption_verification.SignatureVerification import org.pgpainless.key.protection.SecretKeyRingProtector @@ -160,7 +161,9 @@ object PgpMsg { "strong", "strike", "code", - "img" + "img", + "details", + "summary", ) private val ALLOWED_ATTRS = mapOf( @@ -172,7 +175,7 @@ object PgpMsg { "p" to arrayOf("color"), "em" to arrayOf("style"), // Typescript: tests rely on this, could potentially remove "td" to arrayOf("width", "height"), - "hr" to arrayOf("color", "height") + "hr" to arrayOf("color", "height"), ) private val ALLOWED_PROTOCOLS = arrayOf( @@ -525,6 +528,19 @@ object PgpMsg { */ fun sanitizeHtmlKeepBasicTags(dirtyHtml: String?): String? { if (dirtyHtml == null) return null + + val originalDocument = Jsoup.parse(dirtyHtml, "", Parser.xmlParser()) + originalDocument.select("div.gmail_quote").firstOrNull()?.let { element -> + //we wrap Gmail quote with 'details' tag + val generation = Element("details").apply { + appendChild(Element("summary")) + appendChild(Element("br")) + } + element.replaceWith(generation) + generation.appendChild(element) + generation.after(Element("br")) + } + val imgContentReplaceable = "IMG_ICON_${generateRandomSuffix()}" var remoteContentReplacedWithLink = false val policyFactory = HtmlPolicyBuilder() @@ -617,7 +633,7 @@ object PgpMsg { .allowAttributesOnElementsExt(ALLOWED_ATTRS) .toFactory() - val cleanHtml1 = policyFactory.sanitize(dirtyHtml) + val cleanHtml1 = policyFactory.sanitize(originalDocument.html()) val document = Jsoup.parse(cleanHtml1) document.outputSettings().prettyPrint(false) @@ -1266,6 +1282,10 @@ object PgpMsg { body { word-wrap: break-word; word-break: break-word; hyphens: auto; margin-left: 0px; padding-left: 0px; } blockquote { border-left: 1px solid #CCCCCC; margin: 0px 0px 0px 10px; padding:10px 0px 0px 10px; } body img { display: inline !important; height: auto !important; max-width: 95% !important; } + details > summary { list-style-type: none; } + details > summary::-webkit-details-marker { display: none; } + details > summary::before { content: '▪▪▪'; color: #31a217; border: 2px solid; border-radius: 5px; padding: 0px 5px 0px 5px; font-size: 75%; } + summary:active:before { opacity: 0.5; } body pre { white-space: pre-wrap !important; } body > div.MsgBlock > table { zoom: 75% } /* table layouts tend to overflow - eg emails from fb */ @media (prefers-color-scheme: dark) { diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/MessagesInThreadListAdapter.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/MessagesInThreadListAdapter.kt index 0950db4344..1e798405a1 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/MessagesInThreadListAdapter.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/adapter/MessagesInThreadListAdapter.kt @@ -55,6 +55,7 @@ import com.flowcrypt.email.security.model.PgpKeyRingDetails import com.flowcrypt.email.security.pgp.PgpDecryptAndOrVerify import com.flowcrypt.email.ui.adapter.recyclerview.itemdecoration.MarginItemDecoration import com.flowcrypt.email.ui.adapter.recyclerview.itemdecoration.VerticalSpaceMarginItemDecoration +import com.flowcrypt.email.ui.widget.EmailWebView import com.flowcrypt.email.ui.widget.TileDrawable import com.flowcrypt.email.util.DateTimeUtil import com.flowcrypt.email.util.GeneralUtil @@ -81,7 +82,8 @@ class MessagesInThreadListAdapter( /** * A cache of the last content height of WebView. It will help to prevent the content blinking */ - private val mapWebViewHeight = mutableMapOf() + private val mapWebViewHeight = mutableMapOf() + private val mapWebViewExpandedStates = mutableMapOf() override fun getItemViewType(position: Int): Int { val item = getItem(position) @@ -151,10 +153,12 @@ class MessagesInThreadListAdapter( super.onViewRecycled(holder) //try to cache the last content height of WebView. It will help to prevent the content blinking if (holder is MessageExpandedViewHolder) { - val position = holder.bindingAdapterPosition + val message = holder.messageSourceInstance ?: return val holderWebViewHeight = holder.binding.emailWebView.height if (holderWebViewHeight != 0) { - mapWebViewHeight[position] = holderWebViewHeight + mapWebViewHeight[message.id] = holderWebViewHeight + mapWebViewExpandedStates[message.id] = + holder.binding.emailWebView.isContentExpandedAfterInitialLoading } } } @@ -240,7 +244,7 @@ class MessagesInThreadListAdapter( itemView.setOnClickListener { onMessageActionsListener.onMessageClick(position, message) if (message.incomingMessageInfo == null) { - mapWebViewHeight.remove(position) + mapWebViewHeight.remove(message.id) } } val senderAddress = messageEntity.generateFromText(context) @@ -300,6 +304,10 @@ class MessagesInThreadListAdapter( private val messageHeadersListAdapter = MessageHeadersListAdapter() private val pgpBadgeListAdapter = PgpBadgeListAdapter() + private var messageSource: Message? = null + + val messageSourceInstance: Message? + get() = messageSource init { initSomeRecyclerViews() @@ -310,6 +318,7 @@ class MessagesInThreadListAdapter( message: Message, onMessageActionsListener: OnMessageActionsListener ) { + messageSource = message binding.layoutHeader.setOnClickListener { onMessageActionsListener.onMessageClick( position, @@ -423,7 +432,8 @@ class MessagesInThreadListAdapter( binding.emailWebView.apply { updateLayoutParams { - height = mapWebViewHeight[position] ?: LayoutParams.WRAP_CONTENT + //this code prevents content blinking + height = mapWebViewHeight[message.id]?.takeIf { it > 0 } ?: LayoutParams.WRAP_CONTENT } } @@ -535,7 +545,7 @@ class MessagesInThreadListAdapter( MsgBlock.Type.DECRYPTED_HTML, MsgBlock.Type.PLAIN_HTML -> { if (!isHtmlDisplayed) { - setupWebView(block) + setupWebView(message, block) isHtmlDisplayed = true } } @@ -577,10 +587,16 @@ class MessagesInThreadListAdapter( } } - private fun setupWebView(block: MsgBlock) { + private fun setupWebView(message: Message, block: MsgBlock) { binding.emailWebView.configure() - val text = block.content?.clip(context, TEXT_MAX_SIZE) ?: "" + val shouldBeExpandedIfPossible = mapWebViewExpandedStates[message.id] ?: false + val text = block.content?.let { + if (shouldBeExpandedIfPossible) it.replaceFirst( + "
", + "
" + ) else it + }?.clip(context, TEXT_MAX_SIZE) ?: "" binding.emailWebView.loadDataWithBaseURL( null, @@ -589,6 +605,17 @@ class MessagesInThreadListAdapter( StandardCharsets.UTF_8.displayName(), null ) + + binding.emailWebView.setOnPageLoadingListener(object : EmailWebView.OnPageLoadingListener { + override fun onPageLoading(newProgress: Int) { + if (newProgress >= 100) { + //to prevent wrong WebView size need to use LayoutParams.WRAP_CONTENT + binding.emailWebView.apply { + updateLayoutParams { height = LayoutParams.WRAP_CONTENT } + } + } + } + }) } private fun genPublicKeyPart( diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/widget/EmailWebView.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/widget/EmailWebView.kt index 7584d1961d..4c27d2db8a 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/widget/EmailWebView.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/widget/EmailWebView.kt @@ -1,12 +1,13 @@ /* * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com - * Contributors: DenBond7 + * Contributors: denbond7 */ package com.flowcrypt.email.ui.widget import android.content.Context import android.content.Intent +import android.graphics.Point import android.net.Uri import android.util.AttributeSet import android.view.View @@ -37,6 +38,33 @@ class EmailWebView : WebView { defStyleAttr ) + val isContentExpandedAfterInitialLoading: Boolean + get() = currentSize.y > sizeOfContentAfterLoading.y + + private var sizeOfContentAfterLoading = Point() + private var currentSize = Point() + + private var isContentLoaded = false + + override fun loadDataWithBaseURL( + baseUrl: String?, + data: String, + mimeType: String?, + encoding: String?, + historyUrl: String? + ) { + isContentLoaded = false + super.loadDataWithBaseURL(baseUrl, data, mimeType, encoding, historyUrl) + } + + override fun onSizeChanged(w: Int, h: Int, ow: Int, oh: Int) { + super.onSizeChanged(w, h, ow, oh) + currentSize = Point(w, h) + if (isContentLoaded && sizeOfContentAfterLoading.x == 0) { + sizeOfContentAfterLoading = Point(w, h) + } + } + /** * This method does job of configure the current [WebView] */ @@ -48,6 +76,7 @@ class EmailWebView : WebView { webChromeClient = object : WebChromeClient() { override fun onProgressChanged(view: WebView, newProgress: Int) { super.onProgressChanged(view, newProgress) + isContentLoaded = newProgress >= 100 onPageLoadingListener?.onPageLoading(newProgress) } } diff --git a/FlowCrypt/src/test/java/com/flowcrypt/email/security/pgp/PgpMsgTest.kt b/FlowCrypt/src/test/java/com/flowcrypt/email/security/pgp/PgpMsgTest.kt index 524f72f6a4..d09452f491 100644 --- a/FlowCrypt/src/test/java/com/flowcrypt/email/security/pgp/PgpMsgTest.kt +++ b/FlowCrypt/src/test/java/com/flowcrypt/email/security/pgp/PgpMsgTest.kt @@ -17,11 +17,14 @@ import com.flowcrypt.email.util.TestUtil import com.google.gson.JsonParser import jakarta.mail.Session import jakarta.mail.internet.MimeMessage +import kotlinx.coroutines.runBlocking import org.bouncycastle.openpgp.PGPPublicKeyRingCollection import org.bouncycastle.openpgp.PGPSecretKeyRing import org.bouncycastle.openpgp.PGPSecretKeyRingCollection import org.jsoup.Jsoup +import org.jsoup.parser.Parser import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Ignore import org.junit.Test @@ -529,6 +532,89 @@ class PgpMsgTest { checkRenderedBlock(block, listOf(RenderedBlock.normal(true, "PLAIN", htmlContent))) } + @Test + fun testGmailQuotesParsingAndHtmlManipulation() { + val mimeMessageRaw = """ + To: default@flowcrypt.test + From: denbond7@flowcrypt.test + Subject: test + Date: Sun, 17 Mar 2019 11:46:37 +0000 + Message-Id: <1552823197874-dd5800d9-54ca1a01-c548da66@flowcrypt.test> + MIME-Version: 1.0 + Content-Type: multipart/alternative; boundary="000000000000a02340062c4d860a" + + --000000000000a02340062c4d860a + Content-Type: text/plain; charset="UTF-8" + Content-Transfer-Encoding: quoted-printable + + Your Android devices are always getting better thanks to new features and + updates rolling out all the time + + On Wed, Jan 22, 2025 at 5:55=E2=80=AFPM Den wrote: + + > Today, Android 15 starts rolling out to Pixel devices. These updates + > include security features that help keep your sensitive health, financial + > and personal information protected from theft and fraud. + > + > -- + > Regards, + > Den + > + + + --=20 + Regards, + Den + + --000000000000a02340062c4d860a + Content-Type: text/html; charset="UTF-8" + Content-Transfer-Encoding: quoted-printable + +
Your Android devices = + are always getting better thanks to new features and updates rolling out al= + l the time

On Wed, Jan 22, 2025 at 5:55 PM Den= + <denbond7@flowcrypt.test= + > wrote:
Today, Android 15 = + starts rolling out to Pixel devices. These updates include security feature= + s that help keep your sensitive health, financial and personal information = + protected from theft and fraud.

--
Regards,
Den

= + --
= +
Regards,
Den
+ + --000000000000a02340062c4d860a-- + """.trimIndent() + + val processedMimeMessageResult = runBlocking { + PgpMsg.processMimeMessage( + MimeMessage(Session.getInstance(Properties()), mimeMessageRaw.toInputStream()), + PGPPublicKeyRingCollection(listOf()), + PGPSecretKeyRingCollection(listOf()), + SecretKeyRingProtector.unprotectedKeys(), + ) + } + + assertEquals(1, processedMimeMessageResult.blocks.size) + + val plainHtmlBlock = processedMimeMessageResult.blocks.first { + it.type == MsgBlock.Type.PLAIN_HTML + } + + val document = Jsoup.parse(requireNotNull(plainHtmlBlock.content), "", Parser.xmlParser()) + assertNotNull(document.select("details").first()) + assertEquals(1, document.select("details").size) + assertNotNull(document.select("summary").first()) + assertEquals(1, document.select("summary").size) + } + private data class RenderedBlock( val rendered: Boolean, val frameColor: String?,