Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -160,7 +161,9 @@ object PgpMsg {
"strong",
"strike",
"code",
"img"
"img",
"details",
"summary",
)

private val ALLOWED_ATTRS = mapOf(
Expand All @@ -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(
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Int, Int>()
private val mapWebViewHeight = mutableMapOf<Long, Int>()
private val mapWebViewExpandedStates = mutableMapOf<Long, Boolean>()

override fun getItemViewType(position: Int): Int {
val item = getItem(position)
Expand Down Expand Up @@ -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
}
}
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -310,6 +318,7 @@ class MessagesInThreadListAdapter(
message: Message,
onMessageActionsListener: OnMessageActionsListener
) {
messageSource = message
binding.layoutHeader.setOnClickListener {
onMessageActionsListener.onMessageClick(
position,
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -535,7 +545,7 @@ class MessagesInThreadListAdapter(

MsgBlock.Type.DECRYPTED_HTML, MsgBlock.Type.PLAIN_HTML -> {
if (!isHtmlDisplayed) {
setupWebView(block)
setupWebView(message, block)
isHtmlDisplayed = true
}
}
Expand Down Expand Up @@ -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(
"<details>",
"<details open>"
) else it
}?.clip(context, TEXT_MAX_SIZE) ?: ""

binding.emailWebView.loadDataWithBaseURL(
null,
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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]
*/
Expand All @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <denbond7@flowcrypt.test> 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

<div dir=3D"ltr"> <span style=3D"color:rgb(95,99,104);font-family:&quot;Goo=
gle Sans&quot;,roboto,arial,helvetica;font-size:16px">Your Android devices =
are always getting better thanks to new features and updates rolling out al=
l the time</span></div><br><div class=3D"gmail_quote gmail_quote_container"=
> <div dir=3D"ltr" class=3D"gmail_attr">On Wed, Jan 22, 2025 at 5:55 PM Den=
&lt;<a href=3D"mailto:denbond7@flowcrypt.test">denbond7@flowcrypt.test</a>=
&gt; wrote:<br></div> <blockquote class=3D"gmail_quote" style=3D"margin:0px=
0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex"> <d=
iv dir=3D"ltr"> <div><span style=3D"color:rgb(95,99,104);font-family:&quot;=
Google Sans&quot;,roboto,arial,helvetica;font-size:16px">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.</span> </div> <div><br></div> <span class=
=3D"gmail_signature_prefix">-- </span><br> <div dir=3D"ltr" class=3D"gmail_=
signature"> <div dir=3D"ltr"> <div> <div dir=3D"ltr">Regards,</div> <div di=
r=3D"ltr">Den</div> </div> </div> </div> </div> </blockquote></div><div><br=
clear=3D"all"></div><div><br></div><span class=3D"gmail_signature_prefix">=
-- </span><br><div dir=3D"ltr" class=3D"gmail_signature"> <div dir=3D"ltr">=
<div>Regards,</div> <div>Den</div> </div></div>

--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?,
Expand Down