diff --git a/app/src/main/java/com/infomaniak/mail/MatomoMail.kt b/app/src/main/java/com/infomaniak/mail/MatomoMail.kt index b252e136692..7084a1bccc1 100644 --- a/app/src/main/java/com/infomaniak/mail/MatomoMail.kt +++ b/app/src/main/java/com/infomaniak/mail/MatomoMail.kt @@ -148,6 +148,7 @@ object MatomoMail : Matomo { DeleteDraft("deleteDraft"), DeleteExecuted("deleteExecuted"), DeleteFromHistory("deleteFromHistory"), + ShowQuote("showQuote"), DeleteQuote("deleteQuote"), DeleteRecipient("deleteRecipient"), DeleteSearch("deleteSearch"), diff --git a/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/ReplyForwardFooterManager.kt b/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/ReplyForwardFooterManager.kt index 95807dc5c79..263410ac228 100644 --- a/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/ReplyForwardFooterManager.kt +++ b/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/ReplyForwardFooterManager.kt @@ -125,17 +125,27 @@ class ReplyForwardFooterManager @Inject constructor(private val appContext: Cont addAndEscapeTextLine("") addAlreadyEscapedBody(previousFullBody) + // We insert an unstyled empty line immediately after the quoted block. This is necessary because deleting styled + // content often leaves behind empty containers with residual formatting (borders, backgrounds). This plain line acts + // as a "clean exit", when the user deletes the quotes up to this point, the editor drops the quotes' inline styles + // completely, allowing the entire block to be removed in one go. + addAndEscapeTextLine("") }.outerHtml() } private fun assembleReplyHtmlFooter(messageReplyHeader: String, previousFullBody: String): String { - val replyRoot = """
""" + val replyRoot = """
""" return parseAndWrapElementInNewDocument(replyRoot).apply { addAndEscapeTextLine("") addAndEscapeTextLine(messageReplyHeader, endWithBr = false) addReplyBlockQuote { addAlreadyEscapedBody(previousFullBody) } + // We insert an unstyled empty line immediately after the quoted block. This is necessary because deleting styled + // content often leaves behind empty containers with residual formatting (borders, backgrounds). This plain line acts + // as a "clean exit", when the user deletes the quotes up to this point, the editor drops the quotes' inline styles + // completely, allowing the entire block to be removed in one go. + addAndEscapeTextLine("") }.outerHtml() } diff --git a/app/src/main/java/com/infomaniak/mail/data/models/javascriptBridge/EditorJavascriptBridge.kt b/app/src/main/java/com/infomaniak/mail/data/models/javascriptBridge/EditorJavascriptBridge.kt new file mode 100644 index 00000000000..e570e4c67bf --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/data/models/javascriptBridge/EditorJavascriptBridge.kt @@ -0,0 +1,42 @@ +/* + * Infomaniak Mail - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.mail.data.models.javascriptBridge + +import android.webkit.JavascriptInterface +import com.infomaniak.core.sentry.SentryLog +import org.json.JSONArray + +class EditorJavascriptBridge( + private val onInlineImagesDeleted: (List) -> Unit, +) { + @JavascriptInterface + fun onInlineImagesDeleted(cidJson: String) { + val jsonArray = runCatching { + JSONArray(cidJson) + }.onFailure { + SentryLog.e(TAG, "Failed to parse CIDs", it) + }.getOrNull() ?: return + + val cids = (0 until jsonArray.length()).map { jsonArray.getString(it) } + onInlineImagesDeleted(cids) + } + + companion object { + private const val TAG = "EditorJavascriptBridge" + } +} diff --git a/app/src/main/java/com/infomaniak/mail/data/models/javascriptBridge/MessageDisplayJavascriptBridge.kt b/app/src/main/java/com/infomaniak/mail/data/models/javascriptBridge/MessageDisplayJavascriptBridge.kt new file mode 100644 index 00000000000..897b681a585 --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/data/models/javascriptBridge/MessageDisplayJavascriptBridge.kt @@ -0,0 +1,59 @@ +/* + * Infomaniak Mail - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.mail.data.models.javascriptBridge + +import android.webkit.JavascriptInterface +import com.infomaniak.mail.utils.SentryDebug + +class MessageDisplayJavascriptBridge( + private val onWebViewFinishedLoading: () -> Unit, +) { + + @JavascriptInterface + fun reportOverScroll(clientWidth: Int, scrollWidth: Int, messageUid: String) { + SentryDebug.sendOverScrolledMessage(clientWidth, scrollWidth, messageUid) + } + + @JavascriptInterface + fun reportError( + errorName: String, + errorMessage: String, + errorStack: String, + scriptFirstLine: String, + messageUid: String, + ) { + val correctErrorStack = fixStackTraceLineNumber(errorStack, scriptFirstLine) + SentryDebug.sendJavaScriptError(errorName, errorMessage, correctErrorStack, messageUid) + } + + @JavascriptInterface + fun webviewFinishedLoading() { + onWebViewFinishedLoading() + } + + private fun fixStackTraceLineNumber(errorStack: String, scriptFirstLine: String): String { + var correctErrorStack = errorStack + val matches = "about:blank:([0-9]+):".toRegex().findAll(correctErrorStack) + matches.forEach { match -> + val lineNumber = match.groupValues[1] + val newLineNumber = lineNumber.toInt() - scriptFirstLine.toInt() + 1 + correctErrorStack = correctErrorStack.replace(match.groupValues[0], "about:blank:$newLineNumber:") + } + return correctErrorStack + } +} diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadAdapter.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadAdapter.kt index ae7976ae8e6..982f6117405 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadAdapter.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadAdapter.kt @@ -65,6 +65,7 @@ import com.infomaniak.mail.databinding.ItemSuperCollapsedBlockBinding import com.infomaniak.mail.ui.main.thread.ThreadAdapter.ThreadAdapterViewHolder import com.infomaniak.mail.ui.main.thread.models.MessageUi import com.infomaniak.mail.ui.main.thread.models.MessageUi.UnsubscribeState +import com.infomaniak.mail.ui.main.thread.webViewClient.MessageDisplayWebViewClient import com.infomaniak.mail.utils.AccountUtils import com.infomaniak.mail.utils.HtmlFormatter import com.infomaniak.mail.utils.MessageBodyUtils @@ -86,7 +87,7 @@ import com.infomaniak.mail.utils.extensions.enableAlgorithmicDarkening import com.infomaniak.mail.utils.extensions.formatSubject import com.infomaniak.mail.utils.extensions.getAttributeColor import com.infomaniak.mail.utils.extensions.indexOfFirstOrNull -import com.infomaniak.mail.utils.extensions.initWebViewClientAndBridge +import com.infomaniak.mail.utils.extensions.initDisplayWebViewClientAndBridge import com.infomaniak.mail.utils.extensions.toDate import com.infomaniak.mail.utils.extensions.toggleChevron import io.sentry.Sentry @@ -797,8 +798,8 @@ class ThreadAdapter( quoteButtonFrameLayout.isVisible = hasQuote initWebViewClientIfNeeded( - message, - threadAdapterCallbacks?.navigateToNewMessageActivity, + message = message, + navigateToNewMessageActivity = threadAdapterCallbacks?.navigateToNewMessageActivity, onPageFinished = { onExpandedMessageLoaded(message.uid) }, onWebViewFinishedLoading = { threadAdapterCallbacks?.onBodyWebViewFinishedLoading?.invoke() }, ) @@ -1122,8 +1123,8 @@ class ThreadAdapter( onAttachmentOptionsClicked = { onAttachmentOptionsClicked?.invoke(it) }, ) - private var _bodyWebViewClient: MessageWebViewClient? = null - private var _fullMessageWebViewClient: MessageWebViewClient? = null + private var _bodyWebViewClient: MessageDisplayWebViewClient? = null + private var _fullMessageWebViewClient: MessageDisplayWebViewClient? = null val bodyWebViewClient get() = _bodyWebViewClient!! val fullMessageWebViewClient get() = _fullMessageWebViewClient!! @@ -1149,7 +1150,7 @@ class ThreadAdapter( } if (_bodyWebViewClient == null) { - _bodyWebViewClient = binding.bodyWebView.initWebViewClientAndBridge( + _bodyWebViewClient = binding.bodyWebView.initDisplayWebViewClientAndBridge( attachments = message.attachments, messageUid = message.uid, shouldLoadDistantResources = shouldLoadDistantResources, @@ -1158,7 +1159,7 @@ class ThreadAdapter( onPageFinished = onPageFinished, onWebViewFinishedLoading = onWebViewFinishedLoading, ) - _fullMessageWebViewClient = binding.fullMessageWebView.initWebViewClientAndBridge( + _fullMessageWebViewClient = binding.fullMessageWebView.initDisplayWebViewClientAndBridge( attachments = message.attachments, messageUid = message.uid, shouldLoadDistantResources = shouldLoadDistantResources, diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/webViewClient/EditorWebViewClient.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/webViewClient/EditorWebViewClient.kt new file mode 100644 index 00000000000..320272323f2 --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/webViewClient/EditorWebViewClient.kt @@ -0,0 +1,37 @@ +/* + * Infomaniak Mail - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.mail.ui.main.thread.webViewClient + +import android.content.Context +import android.webkit.WebView +import com.infomaniak.mail.data.models.Attachment + +class EditorWebViewClient( + context: Context, + cidDictionary: Map, + shouldLoadDistantResources: Boolean, + private val onPageFinished: () -> Unit, +) : MessageWebViewClient( + context, + cidDictionary, + shouldLoadDistantResources, +) { + override fun onPageFinished(webView: WebView, url: String?) { + onPageFinished() + } +} diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/webViewClient/MessageDisplayWebViewClient.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/webViewClient/MessageDisplayWebViewClient.kt new file mode 100644 index 00000000000..07554f1d5d7 --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/webViewClient/MessageDisplayWebViewClient.kt @@ -0,0 +1,94 @@ +/* + * Infomaniak Mail - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.mail.ui.main.thread.webViewClient + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.webkit.WebResourceRequest +import android.webkit.WebView +import com.infomaniak.core.ui.showToast +import com.infomaniak.core.ui.view.toDp +import com.infomaniak.lib.richhtmleditor.looselyEscapeAsStringLiteralForJs +import com.infomaniak.mail.R +import com.infomaniak.mail.data.models.Attachment +import com.infomaniak.mail.utils.Utils.runCatchingRealm +import com.infomaniak.mail.utils.WebViewVersionUtils +import io.sentry.Sentry +import io.sentry.SentryLevel + +class MessageDisplayWebViewClient( + private val context: Context, + cidDictionary: Map, + private val messageUid: String, + shouldLoadDistantResources: Boolean, + onBlockedResourcesDetected: () -> Unit, + private val navigateToNewMessageActivity: ((Uri) -> Unit)?, + private val onPageFinished: (() -> Unit)?, +) : MessageWebViewClient( + context, + cidDictionary, + shouldLoadDistantResources, + onBlockedResourcesDetected, +) { + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + request.url?.let { uri -> + + if (uri.scheme == "mailto") { + navigateToNewMessageActivity?.invoke(uri) + return true + } + + runCatching { + val intent = Intent(Intent.ACTION_VIEW, uri) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + }.onFailure { + context.showToast(R.string.startActivityCantHandleAction) + } + } + return true + } + + override fun onPageFinished(webView: WebView, url: String?) { + runCatchingRealm { + val widthInDp = webView.width.toDp(webView) + if (widthInDp <= 0) { + val versionData = WebViewVersionUtils.getWebViewVersionData(context) + + Sentry.captureMessage("Zero width webview detected onPageFinished which prevents message width's normalization") { scope -> + scope.level = SentryLevel.WARNING + scope.setExtra("width", webView.width.toString()) + scope.setExtra("measuredWidth", webView.measuredWidth.toString()) + scope.setExtra("height", webView.height.toString()) + scope.setExtra("measuredHeight", webView.measuredHeight.toString()) + scope.setTag( + "webview version", + "${versionData?.webViewPackageName}: ${versionData?.versionName} - ${versionData?.majorVersion}" + ) + scope.setTag("visibility", webView.visibility.toString()) + scope.setTag("messageUid", messageUid) + scope.setTag("shouldLoadDistantResources", shouldLoadDistantResources.toString()) + } + } + val escapedMessageUid = looselyEscapeAsStringLiteralForJs(messageUid) + webView.loadUrl("javascript:removeAllProperties(); normalizeMessageWidth($widthInDp, $escapedMessageUid)") + onPageFinished?.invoke() + } + } +} diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/MessageWebViewClient.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/webViewClient/MessageWebViewClient.kt similarity index 56% rename from app/src/main/java/com/infomaniak/mail/ui/main/thread/MessageWebViewClient.kt rename to app/src/main/java/com/infomaniak/mail/ui/main/thread/webViewClient/MessageWebViewClient.kt index 7976f15aa6e..2e8c55f878e 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/MessageWebViewClient.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/webViewClient/MessageWebViewClient.kt @@ -15,38 +15,28 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.infomaniak.mail.ui.main.thread +package com.infomaniak.mail.ui.main.thread.webViewClient import android.content.Context -import android.content.Intent -import android.net.Uri import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView import android.webkit.WebViewClient -import com.infomaniak.core.ui.showToast -import com.infomaniak.core.ui.view.toDp -import com.infomaniak.mail.R import com.infomaniak.mail.data.api.ApiRepository import com.infomaniak.mail.data.models.Attachment import com.infomaniak.mail.utils.LocalStorageUtils import com.infomaniak.mail.utils.Utils import com.infomaniak.mail.utils.Utils.runCatchingRealm -import com.infomaniak.mail.utils.WebViewVersionUtils.getWebViewVersionData -import io.sentry.Sentry -import io.sentry.SentryLevel import kotlinx.coroutines.runBlocking import java.io.ByteArrayInputStream -class MessageWebViewClient( +abstract class MessageWebViewClient( private val context: Context, - private val cidDictionary: MutableMap, - private val messageUid: String, - private var shouldLoadDistantResources: Boolean, + private val cidDictionary: Map, + private var _shouldLoadDistantResources: Boolean, private val onBlockedResourcesDetected: (() -> Unit)? = null, - private val navigateToNewMessageActivity: ((Uri) -> Unit)?, - private val onPageFinished: (() -> Unit)? = null, ) : WebViewClient() { + val shouldLoadDistantResources get() = _shouldLoadDistantResources private val emptyResource by lazy { WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream(ByteArray(0))) } @@ -74,7 +64,7 @@ class MessageWebViewClient( } ?: emptyResource } - val shouldLoadResource = shouldLoadDistantResources + val shouldLoadResource = _shouldLoadDistantResources || url?.scheme.equals(DATA_SCHEME, ignoreCase = true) || trustedUrls.any { it.find(url.toString()) != null } @@ -86,54 +76,8 @@ class MessageWebViewClient( } }.getOrDefault(super.shouldInterceptRequest(view, request)) - override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { - request.url?.let { uri -> - - if (uri.scheme == "mailto") { - navigateToNewMessageActivity?.invoke(uri) - return true - } - - runCatching { - val intent = Intent(Intent.ACTION_VIEW, uri) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(intent) - }.onFailure { - context.showToast(R.string.startActivityCantHandleAction) - } - } - return true - } - - override fun onPageFinished(webView: WebView, url: String?) { - runCatchingRealm { - val widthInDp = webView.width.toDp(webView) - if (widthInDp <= 0) { - val versionData = getWebViewVersionData(context) - - Sentry.captureMessage("Zero width webview detected onPageFinished which prevents message width's normalization") { scope -> - scope.level = SentryLevel.WARNING - scope.setExtra("width", webView.width.toString()) - scope.setExtra("measuredWidth", webView.measuredWidth.toString()) - scope.setExtra("height", webView.height.toString()) - scope.setExtra("measuredHeight", webView.measuredHeight.toString()) - scope.setTag( - "webview version", - "${versionData?.webViewPackageName}: ${versionData?.versionName} - ${versionData?.majorVersion}" - ) - scope.setTag("visibility", webView.visibility.toString()) - scope.setTag("messageUid", messageUid) - scope.setTag("shouldLoadDistantResources", shouldLoadDistantResources.toString()) - } - } - - webView.loadUrl("javascript:removeAllProperties(); normalizeMessageWidth($widthInDp, '$messageUid')") - onPageFinished?.invoke() - } - } - fun unblockDistantResources() { - shouldLoadDistantResources = true + _shouldLoadDistantResources = true } companion object { diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/AiPromptFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/AiPromptFragment.kt index c5fb816ba9a..20d93d62543 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/newMessage/AiPromptFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/AiPromptFragment.kt @@ -92,7 +92,9 @@ class AiPromptFragment : Fragment() { closeButton.setOnClickListener { newMessageFragment.closeAiPrompt() } generateButton.setOnClickListener { - newMessageFragment.navigateToPropositionFragment() + viewLifecycleOwner.lifecycleScope.launch { + newMessageFragment.navigateToPropositionFragment() + } } // When the app is recreated or the prompt is opened when coming back from AiPropositionFragment, diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/BodyContentPayload.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/BodyContentPayload.kt index 88f29030fda..bea914dbea5 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/newMessage/BodyContentPayload.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/BodyContentPayload.kt @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2024 Infomaniak Network SA + * Copyright (C) 2024-2026 Infomaniak Network SA * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -24,7 +24,8 @@ package com.infomaniak.mail.ui.newMessage data class BodyContentPayload(val content: String, val type: BodyContentType) { companion object { - fun emptyBody() = BodyContentPayload(content = "", type = BodyContentType.TEXT_PLAIN_WITHOUT_HTML) + // Add some empty lines in the body so the body focus on these lines and not in the signature + fun emptyBody() = BodyContentPayload(content = "

", type = BodyContentType.HTML_SANITIZED) } } diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/EditorContentManager.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/EditorContentManager.kt index e8d2ff09a93..ece1a2968f9 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/newMessage/EditorContentManager.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/EditorContentManager.kt @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2024 Infomaniak Network SA + * Copyright (C) 2024-2026 Infomaniak Network SA * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -28,57 +28,59 @@ import javax.inject.Inject @FragmentScoped class EditorContentManager @Inject constructor() { - fun setContent(editor: RichHtmlEditorWebView, bodyContentPayload: BodyContentPayload) = with(editor) { - when (bodyContentPayload.type) { - BodyContentType.HTML_SANITIZED -> setSanitizedHtml(bodyContentPayload.content) - BodyContentType.HTML_UNSANITIZED -> setUnsanitizedHtml(bodyContentPayload.content) - BodyContentType.TEXT_PLAIN_WITH_HTML -> setPlainTextAndInterpretHtml(bodyContentPayload.content) - BodyContentType.TEXT_PLAIN_WITHOUT_HTML -> setPlainTextAndEscapeHtml(bodyContentPayload.content) - } + fun setContent(editor: RichHtmlEditorWebView, bodyContentPayload: BodyContentPayload) { + editor.setHtml(bodyContentPayload.toSanitizedHtml()) } - private fun RichHtmlEditorWebView.setSanitizedHtml(html: String) = setHtml(html) + companion object { + private val NEW_LINES_REGEX = "(\\r\\n|\\n)".toRegex() - private fun RichHtmlEditorWebView.setUnsanitizedHtml(html: String) = setSanitizedHtml(html.sanitize()) + fun BodyContentPayload.toSanitizedHtml(): String { + return when (type) { + BodyContentType.HTML_SANITIZED -> content + BodyContentType.HTML_UNSANITIZED -> getUnsanitizedHtml(content) + BodyContentType.TEXT_PLAIN_WITH_HTML -> getPlainTextAndInterpretHtml(content) + BodyContentType.TEXT_PLAIN_WITHOUT_HTML -> getPlainTextAndEscapeHtml(content) + } + } - private fun RichHtmlEditorWebView.setPlainTextAndInterpretHtml(text: String) { - setSanitizedHtml(text.replaceNewLines().sanitize()) - } + private fun getUnsanitizedHtml(html: String) = html.sanitize() - private fun RichHtmlEditorWebView.setPlainTextAndEscapeHtml(text: String) { - setSanitizedHtml(text.escapeHtmlCharacters().replaceNewLines()) - } + private fun getPlainTextAndInterpretHtml(text: String): String { + return text.replaceNewLines().sanitize() + } - private fun String.escapeHtmlCharacters(): String = Html.escapeHtml(this) + private fun getPlainTextAndEscapeHtml(text: String): String { + return text.escapeHtmlCharacters().replaceNewLines() + } - private fun String.replaceNewLines(): String = replace(NEW_LINES_REGEX, "
") + private fun String.escapeHtmlCharacters(): String = Html.escapeHtml(this) - private fun String.sanitize(): String = HtmlSanitizer.getInstance() - .sanitize(jsoupParseWithLog(this)) - .apply { outputSettings().prettyPrint(false) } - .getHtmlWithoutDocumentWrapping() + private fun String.replaceNewLines(): String = replace(NEW_LINES_REGEX, "
") - // Jsoup wraps parsed html inside an and tag. This gives us a wrapped form of the html content. While the editor - // can handle this wrapped HTML without issues, it will also output the HTML in this wrapped form if given one as input. - // If the HTML received from the API is unwrapped, the sanitization process will wrap it, leading to failed comparisons due to - // this wrapping, during draft snapshot comparisons, even when the actual content hasn't changed. - // This method checks if the HTML is wrapped with an tag containing exactly one empty and one tag. - // If this wrapping is detected, the method unwraps the HTML and returns only the content within the tag. - private fun Document.getHtmlWithoutDocumentWrapping(): String { - val html = root().firstElementChild() ?: return html() - val nodeSize = html.childNodeSize() - val elements = html.children() + private fun String.sanitize(): String = HtmlSanitizer.getInstance() + .sanitize(jsoupParseWithLog(this)) + .apply { outputSettings().prettyPrint(false) } + .getHtmlWithoutDocumentWrapping() - val canRemoveDocumentWrapping = nodeSize == 2 - && elements.count() == 2 - && elements[0].tagName().uppercase() == "HEAD" - && elements[0].childNodeSize() == 0 - && elements[1].tagName().uppercase() == "BODY" + // Jsoup wraps parsed html inside an and tag. This gives us a wrapped form of the html content. While the editor + // can handle this wrapped HTML without issues, it will also output the HTML in this wrapped form if given one as input. + // If the HTML received from the API is unwrapped, the sanitization process will wrap it, leading to failed comparisons due to + // this wrapping, during draft snapshot comparisons, even when the actual content hasn't changed. + // This method checks if the HTML is wrapped with an tag containing exactly one empty and one tag. + // If this wrapping is detected, the method unwraps the HTML and returns only the content within the tag. + private fun Document.getHtmlWithoutDocumentWrapping(): String { + val html = root().firstElementChild() ?: return html() + val nodeSize = html.childNodeSize() + val elements = html.children() - return if (canRemoveDocumentWrapping) body().html() else html() - } + val canRemoveDocumentWrapping = nodeSize == 2 + && elements.count() == 2 + && elements[0].tagName().uppercase() == "HEAD" + && elements[0].childNodeSize() == 0 + && elements[1].tagName().uppercase() == "BODY" - companion object { - private val NEW_LINES_REGEX = "(\\r\\n|\\n)".toRegex() + return if (canRemoveDocumentWrapping) body().html() else html() + } } } diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageAiManager.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageAiManager.kt index d07c8e434b1..04041075afd 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageAiManager.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageAiManager.kt @@ -32,6 +32,7 @@ import androidx.lifecycle.lifecycleScope import com.infomaniak.core.ksuite.data.KSuite import com.infomaniak.core.legacy.utils.hideKeyboard import com.infomaniak.core.legacy.utils.safeNavigate +import com.infomaniak.lib.richhtmleditor.executor.JsExecutableMethod import com.infomaniak.mail.MatomoMail.MatomoName import com.infomaniak.mail.MatomoMail.trackAiWriterEvent import com.infomaniak.mail.R @@ -39,6 +40,12 @@ import com.infomaniak.mail.data.LocalSettings import com.infomaniak.mail.data.models.FeatureFlag import com.infomaniak.mail.data.models.ai.AiPromptOpeningStatus import com.infomaniak.mail.databinding.FragmentNewMessageBinding +import com.infomaniak.mail.ui.newMessage.EditorContentManager.Companion.toSanitizedHtml +import com.infomaniak.mail.utils.HtmlFormatter.Companion.getCheckIsEditorBodyEmptyScript +import com.infomaniak.mail.utils.MessageBodyUtils.INFOMANIAK_FORWARD_QUOTE_HTML_CLASS_NAME +import com.infomaniak.mail.utils.MessageBodyUtils.INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME +import com.infomaniak.mail.utils.MessageBodyUtils.INFOMANIAK_SIGNATURE_HTML_CLASS_NAME +import com.infomaniak.mail.utils.WebViewUtils.Companion.evaluateJs import com.infomaniak.mail.utils.openKSuiteProBottomSheet import com.infomaniak.mail.utils.openMailPremiumBottomSheet import com.infomaniak.mail.utils.openMyKSuiteUpgradeBottomSheet @@ -55,7 +62,6 @@ import com.infomaniak.core.legacy.R as RCore @FragmentScoped class NewMessageAiManager @Inject constructor( @ActivityContext private val activityContext: Context, - private val editorContentManager: EditorContentManager, private val localSettings: LocalSettings, ) : NewMessageManager() { @@ -67,6 +73,7 @@ class NewMessageAiManager @Inject constructor( private val animationDuration by lazy { resources.getInteger(R.integer.aiPromptAnimationDuration).toLong() } private val scrimOpacity by lazy { ResourcesCompat.getFloat(context.resources, R.dimen.scrimOpacity) } private val black by lazy { context.getColor(RCore.color.black) } + private val checkIsEditorBodyEmptyScript by lazy { activityContext.getCheckIsEditorBodyEmptyScript() } private var aiPromptFragment: AiPromptFragment? = null @@ -94,8 +101,18 @@ class NewMessageAiManager @Inject constructor( fun observeAiOutput() = with(binding) { aiViewModel.aiOutputToInsert.observe(viewLifecycleOwner) { (subject, content) -> + newMessageViewModel.setPlaceholderVisibility(false) subject?.let(subjectTextField::setText) - editorContentManager.setContent(editorWebView, BodyContentPayload(content, BodyContentType.TEXT_PLAIN_WITH_HTML)) + setAiContent(content) + } + } + + private fun setAiContent(content: String) { + val bodyContent = BodyContentPayload(content, BodyContentType.TEXT_PLAIN_WITH_HTML) + val sanitizedContent = bodyContent.toSanitizedHtml() + + viewLifecycleOwner.lifecycleScope.launch { + binding.editorWebView.executeJsMethodWhenEditorIsSetup(JsExecutableMethod("setAiOutputContent", sanitizedContent)) } } @@ -207,15 +224,25 @@ class NewMessageAiManager @Inject constructor( } } - fun navigateToPropositionFragment() { - + suspend fun navigateToPropositionFragment() { + calculateAiPropositionData() closeAiPrompt(becauseOfGeneration = true) resetAiProposition() + } + + private suspend fun calculateAiPropositionData() { + val isSubjectBlank = fragment.isSubjectBlank() + val formattedScript = checkIsEditorBodyEmptyScript.format( + INFOMANIAK_SIGNATURE_HTML_CLASS_NAME, + INFOMANIAK_FORWARD_QUOTE_HTML_CLASS_NAME, + INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME, + ) + val isBodyBlank = binding.editorWebView.evaluateJs(formattedScript) == "true" fragment.safeNavigate( NewMessageFragmentDirections.actionNewMessageFragmentToAiPropositionFragment( - isSubjectBlank = fragment.isSubjectBlank(), - isBodyBlank = binding.editorWebView.isEmptyFlow.value ?: true, + isSubjectBlank = isSubjectBlank, + isBodyBlank = isBodyBlank, ), ) } diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageFragment.kt index 86559e0a00a..2614d721abd 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageFragment.kt @@ -21,9 +21,7 @@ package com.infomaniak.mail.ui.newMessage import android.annotation.SuppressLint import android.app.Activity -import android.content.ClipDescription import android.content.Intent -import android.content.res.Configuration import android.os.Bundle import android.text.InputFilter import android.text.Spanned @@ -31,13 +29,11 @@ import android.transition.TransitionManager import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.webkit.WebView import android.widget.ListPopupWindow import android.widget.PopupWindow import androidx.activity.addCallback import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.content.res.AppCompatResources -import androidx.constraintlayout.widget.Group import androidx.core.view.forEach import androidx.core.view.isGone import androidx.core.view.isVisible @@ -62,13 +58,14 @@ import com.infomaniak.lib.richhtmleditor.StatusCommand.ITALIC import com.infomaniak.lib.richhtmleditor.StatusCommand.STRIKE_THROUGH import com.infomaniak.lib.richhtmleditor.StatusCommand.UNDERLINE import com.infomaniak.lib.richhtmleditor.StatusCommand.UNORDERED_LIST +import com.infomaniak.lib.richhtmleditor.executor.JsExecutableMethod +import com.infomaniak.lib.richhtmleditor.looselyEscapeAsStringLiteralForJs import com.infomaniak.mail.MatomoMail.MatomoName import com.infomaniak.mail.MatomoMail.trackAttachmentActionsEvent import com.infomaniak.mail.MatomoMail.trackNewMessageEvent import com.infomaniak.mail.MatomoMail.trackScheduleSendEvent import com.infomaniak.mail.R import com.infomaniak.mail.data.LocalSettings -import com.infomaniak.mail.data.LocalSettings.ExternalContent import com.infomaniak.mail.data.models.Attachment import com.infomaniak.mail.data.models.AttachmentDisposition import com.infomaniak.mail.data.models.FeatureFlag @@ -93,14 +90,21 @@ import com.infomaniak.mail.ui.newMessage.NewMessageViewModel.UiFrom import com.infomaniak.mail.ui.newMessage.encryption.EncryptionMessageManager import com.infomaniak.mail.ui.newMessage.encryption.EncryptionViewModel import com.infomaniak.mail.utils.AccountUtils -import com.infomaniak.mail.utils.HtmlUtils.processCids -import com.infomaniak.mail.utils.JsoupParserUtil.jsoupParseWithLog +import com.infomaniak.mail.utils.HtmlFormatter.Companion.getCustomDarkMode +import com.infomaniak.mail.utils.HtmlFormatter.Companion.getCustomEditorStyle +import com.infomaniak.mail.utils.HtmlFormatter.Companion.getCustomStyle +import com.infomaniak.mail.utils.HtmlFormatter.Companion.getDeletedInlineImagesObserverScript +import com.infomaniak.mail.utils.HtmlFormatter.Companion.getEditorJsBridgeScript +import com.infomaniak.mail.utils.HtmlFormatter.Companion.getFixStyleScript +import com.infomaniak.mail.utils.HtmlFormatter.Companion.getIncludeQuotesScript +import com.infomaniak.mail.utils.HtmlFormatter.Companion.getReplaceSignatureScript +import com.infomaniak.mail.utils.HtmlFormatter.Companion.getSetAiContentScript +import com.infomaniak.mail.utils.MessageBodyUtils.EDITOR_LOCAL_SIGNATURE_ID +import com.infomaniak.mail.utils.MessageBodyUtils.INFOMANIAK_FORWARD_QUOTE_HTML_CLASS_NAME +import com.infomaniak.mail.utils.MessageBodyUtils.INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME +import com.infomaniak.mail.utils.MessageBodyUtils.INFOMANIAK_SIGNATURE_HTML_CLASS_NAME import com.infomaniak.mail.utils.SentryDebug import com.infomaniak.mail.utils.SignatureUtils -import com.infomaniak.mail.utils.UiUtils.PRIMARY_COLOR_CODE -import com.infomaniak.mail.utils.Utils -import com.infomaniak.mail.utils.WebViewUtils -import com.infomaniak.mail.utils.WebViewUtils.Companion.destroyAndClearHistory import com.infomaniak.mail.utils.WebViewUtils.Companion.setupNewMessageWebViewSettings import com.infomaniak.mail.utils.extensions.AttachmentExt import com.infomaniak.mail.utils.extensions.AttachmentExt.openAttachment @@ -110,10 +114,9 @@ import com.infomaniak.mail.utils.extensions.applyWindowInsetsListener import com.infomaniak.mail.utils.extensions.bindAlertToViewLifecycle import com.infomaniak.mail.utils.extensions.changeToolbarColorOnScroll import com.infomaniak.mail.utils.extensions.enableAlgorithmicDarkening -import com.infomaniak.mail.utils.extensions.getAttributeColor import com.infomaniak.mail.utils.extensions.ime -import com.infomaniak.mail.utils.extensions.initWebViewClientAndBridge -import com.infomaniak.mail.utils.extensions.loadCss +import com.infomaniak.mail.utils.extensions.initEditorWebviewBridge +import com.infomaniak.mail.utils.extensions.initEditorWebviewClient import com.infomaniak.mail.utils.extensions.navigateToDownloadProgressDialog import com.infomaniak.mail.utils.extensions.systemBars import com.infomaniak.mail.utils.extensions.valueOrEmpty @@ -125,14 +128,11 @@ import io.sentry.Sentry import io.sentry.SentryLevel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.launch import splitties.experimental.ExperimentalSplittiesApi import java.util.Date import javax.inject.Inject -import androidx.appcompat.R as RAndroid @AndroidEntryPoint class NewMessageFragment : Fragment() { @@ -144,6 +144,13 @@ class NewMessageFragment : Fragment() { // extras aren't yet initialized, so we don't use the `navArgs` here. requireActivity().intent?.extras?.let(NewMessageActivityArgs::fromBundle) ?: NewMessageActivityArgs() } + private val replaceSignatureScript by lazy { requireContext().getReplaceSignatureScript() } + private val includeQuotesScript by lazy { requireContext().getIncludeQuotesScript() } + private val deletedInlineImagesObserverScript by lazy { requireContext().getDeletedInlineImagesObserverScript() } + private val editorJsBridgeScript by lazy { requireContext().getEditorJsBridgeScript() } + private val fixStyle by lazy { requireContext().getFixStyleScript() } + private val setAiContentScript by lazy { requireContext().getSetAiContentScript() } + private val newMessageFragmentArgs: NewMessageFragmentArgs by navArgs() private val newMessageViewModel: NewMessageViewModel by activityViewModels() private val aiViewModel: AiViewModel by activityViewModels() @@ -155,14 +162,10 @@ class NewMessageFragment : Fragment() { private var addressListPopupWindow: ListPopupWindow? = null - private var quoteWebView: WebView? = null - private var signatureWebView: WebView? = null - private val signatureAdapter = SignatureAdapter(::onSignatureClicked) private val attachmentAdapter inline get() = binding.attachmentsRecyclerView.adapter as AttachmentAdapter private val newMessageActivity by lazy { requireActivity() as NewMessageActivity } - private val webViewUtils by lazy { WebViewUtils(requireContext()) } @Inject lateinit var editorContentManager: EditorContentManager @@ -219,7 +222,6 @@ class NewMessageFragment : Fragment() { bindAlertToViewLifecycle(descriptionDialog) - setWebViewReference() initMailbox() initUi() initializeDraft() @@ -232,9 +234,10 @@ class NewMessageFragment : Fragment() { observeAttachments() observeImportAttachmentsResult() observeBodyLoader() - observeUiSignature() - observeUiQuote() observeShimmering() + observePlaceholderVisibility() + observeQuotesButtonVisibility() + observeQuotesInclusion() setupBackActionHandler() @@ -361,21 +364,6 @@ class NewMessageFragment : Fragment() { ) } - private fun setWebViewReference() { - quoteWebView = binding.quoteWebView - signatureWebView = binding.signatureWebView - } - - override fun onConfigurationChanged(newConfig: Configuration) { - newMessageViewModel.uiSignatureLiveData.value?.let { _ -> - binding.signatureWebView.reload() - } - newMessageViewModel.uiQuoteLiveData.value?.let { _ -> - binding.quoteWebView.reload() - } - super.onConfigurationChanged(newConfig) - } - override fun onDestroyView() { // This block of code is needed in order to keep and reload the content of the editor across configuration changes. binding.editorWebView.exportHtml { html -> @@ -383,10 +371,6 @@ class NewMessageFragment : Fragment() { } addressListPopupWindow = null - quoteWebView?.destroyAndClearHistory() - quoteWebView = null - signatureWebView?.destroyAndClearHistory() - signatureWebView = null TransitionManager.endTransitions(binding.root) super.onDestroyView() _binding = null @@ -418,9 +402,6 @@ class NewMessageFragment : Fragment() { toolbar.setNavigationOnClickListener { activity?.onBackPressedDispatcher?.onBackPressed() } changeToolbarColorOnScroll(appBarLayout, compositionNestedScrollView) - signatureWebView.enableAlgorithmicDarkening(true) - quoteWebView.enableAlgorithmicDarkening(true) - attachmentsRecyclerView.adapter = AttachmentAdapter( shouldDisplayCloseButton = true, onDelete = ::onDeleteAttachment, @@ -452,6 +433,7 @@ class NewMessageFragment : Fragment() { }) initEditorUi() + setupShowQuotesButton() viewLifecycleOwner.lifecycleScope.launch { val mailbox = newMessageViewModel.currentMailbox() @@ -466,38 +448,48 @@ class NewMessageFragment : Fragment() { } private fun initEditorUi() = with(binding) { + editorWebView.settings.setupNewMessageWebViewSettings() + editorWebView.initEditorWebviewBridge(onInlineImagesDeleted = newMessageViewModel::deleteInlineAttachments) editorWebView.subscribeToStates(setOf(BOLD, ITALIC, UNDERLINE, STRIKE_THROUGH, UNORDERED_LIST, CREATE_LINK)) setEditorStyle() - handleEditorPlaceholderVisibility() - + setEditorScript() editorAiAnimation.setAnimation(R.raw.euria) - setToolbarEnabledStatus(false) - disableButtonsWhenFocusIsLost() + removeAllProperties() + handleFocusChanges() } private fun setEditorStyle() = with(binding.editorWebView) { enableAlgorithmicDarkening(isEnabled = true) - if (context.isNightModeEnabled()) addCss(context.loadCss(R.raw.custom_dark_mode)) - - val customColors = listOf(PRIMARY_COLOR_CODE to context.getAttributeColor(RAndroid.attr.colorPrimary)) - addCss(context.loadCss(R.raw.style, customColors)) - addCss(context.loadCss(R.raw.editor_style, customColors)) + if (context.isNightModeEnabled()) addCss(context.getCustomDarkMode()) + addCss(context.getCustomStyle()) + addCss(context.getCustomEditorStyle()) } - private fun handleEditorPlaceholderVisibility() { - val isPlaceholderVisible = combine( - binding.editorWebView.isEmptyFlow.filterNotNull(), - newMessageViewModel.isShimmering, - ) { isEditorEmpty, isShimmering -> isEditorEmpty && !isShimmering } + private fun setEditorScript() = with(binding.editorWebView) { + addScript(fixStyle) + addScript(editorJsBridgeScript) + addScript(deletedInlineImagesObserverScript) - isPlaceholderVisible - .onEach { isVisible -> binding.newMessagePlaceholder.isVisible = isVisible } - .launchIn(lifecycleScope) + val formattedAiContentScript = setAiContentScript.format( + INFOMANIAK_SIGNATURE_HTML_CLASS_NAME, + INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME, + INFOMANIAK_FORWARD_QUOTE_HTML_CLASS_NAME + ) + addScript(formattedAiContentScript) + } + + private fun removeAllProperties() = viewLifecycleOwner.lifecycleScope.launch { + binding.editorWebView.executeJsMethodWhenEditorIsSetup(JsExecutableMethod("removeAllProperties")) } - private fun disableButtonsWhenFocusIsLost() { - newMessageViewModel.isEditorWebViewFocusedLiveData.observe(viewLifecycleOwner, ::setToolbarEnabledStatus) + private fun handleFocusChanges() = with(newMessageViewModel) { + isEditorWebViewFocusedLiveData.observe(viewLifecycleOwner) { isFocused -> + setToolbarEnabledStatus(isFocused) + if (isFocused && isPlaceHolderVisible.value) { + setPlaceholderVisibility(isVisible = false) + } + } } private fun setToolbarEnabledStatus(isEnabled: Boolean) { @@ -529,74 +521,46 @@ class NewMessageFragment : Fragment() { } } - private fun configureUiWithDraftData(draft: Draft) = with(binding) { - - // Signature - signatureWebView.apply { - settings.setupNewMessageWebViewSettings() - initWebViewClientAndBridge( - attachments = emptyList(), - messageUid = "SIGNATURE-${draft.messageUid}", - shouldLoadDistantResources = true, - navigateToNewMessageActivity = null, - ) - } - removeSignature.setOnClickListener { - trackNewMessageEvent(MatomoName.DeleteSignature) - newMessageViewModel.uiSignatureLiveData.value = null - } - - // Quote - quoteWebView.apply { - settings.setupNewMessageWebViewSettings() - val alwaysShowExternalContent = localSettings.externalContent == ExternalContent.ALWAYS - initWebViewClientAndBridge( - attachments = draft.attachments, - messageUid = "QUOTE-${draft.messageUid}", - shouldLoadDistantResources = alwaysShowExternalContent || newMessageViewModel.shouldLoadDistantResources(), - navigateToNewMessageActivity = null, - ) - } - removeQuote.setOnClickListener { - trackNewMessageEvent(MatomoName.DeleteQuote) - removeInlineAttachmentsUsedInQuote() - newMessageViewModel.uiQuoteLiveData.value = null - } + private fun configureUiWithDraftData(draft: Draft) = with(binding.editorWebView) { + val alwaysShowExternalContent = localSettings.externalContent == LocalSettings.ExternalContent.ALWAYS + webViewClient = initEditorWebviewClient( + attachments = draft.attachments, + shouldLoadDistantResources = alwaysShowExternalContent || newMessageViewModel.shouldLoadDistantResources(), + onPageFinished = { notifyPageHasLoaded() }, + ) } - private fun removeInlineAttachmentsUsedInQuote() = with(newMessageViewModel) { - uiQuoteLiveData.value?.let { html -> - attachmentsLiveData.value?.filterOutHtmlCids(html)?.let { attachmentsLiveData.value = it } + private fun observeQuotesButtonVisibility() = viewLifecycleOwner.lifecycleScope.launch { + combine( + newMessageViewModel.isQuotesButtonVisible, + newMessageViewModel.isShimmering.filterNot { it } + ) { isQuoteVisible, _ -> + isQuoteVisible + }.collect { isQuoteButtonVisible -> + binding.showQuotesButton.isVisible = isQuoteButtonVisible } } - private fun List.filterOutHtmlCids(html: String): List { - return buildList { - addAll(this@filterOutHtmlCids) - - jsoupParseWithLog(html).processCids( - attachments = this@filterOutHtmlCids, - associateDataToCid = { it }, - onCidImageFound = { attachment, _ -> - remove(attachment) - } - ) + private fun observePlaceholderVisibility() = viewLifecycleOwner.lifecycleScope.launch { + newMessageViewModel.isPlaceHolderVisible.collect { isPlaceholderVisible -> + binding.newMessagePlaceholder.isVisible = isPlaceholderVisible } } - private fun WebView.loadSignatureContent(html: String, webViewGroup: Group) { - val processedHtml = webViewUtils.processSignatureHtmlForDisplay(html, context.isNightModeEnabled()) - loadProcessedContent(processedHtml, webViewGroup) - } - - private fun WebView.loadContent(html: String, webViewGroup: Group) { - val processedHtml = webViewUtils.processHtmlForDisplay(html = html, isDisplayedInDarkMode = context.isNightModeEnabled()) - loadProcessedContent(processedHtml, webViewGroup) + private fun observeQuotesInclusion() = viewLifecycleOwner.lifecycleScope.launch { + for (quote in newMessageViewModel.quotesToIncludeChannel) { + val escapedQuote = looselyEscapeAsStringLiteralForJs(quote) + val includeQuoteScript = includeQuotesScript.format(escapedQuote) + binding.editorWebView.evaluateJavascript(includeQuoteScript, null) + } } - private fun WebView.loadProcessedContent(processedHtml: String, webViewGroup: Group) { - webViewGroup.isVisible = processedHtml.isNotBlank() - loadDataWithBaseURL("", processedHtml, ClipDescription.MIMETYPE_TEXT_HTML, Utils.UTF_8, "") + private fun setupShowQuotesButton() { + binding.showQuotesButton.setOnClickListener { + trackNewMessageEvent(MatomoName.ShowQuote) + newMessageViewModel.setQuotesButtonVisibility(isVisible = false) + newMessageViewModel.includeQuotes() + } } private fun setupFromField(signatures: List) = with(binding) { @@ -636,6 +600,24 @@ class NewMessageFragment : Fragment() { addressListPopupWindow?.dismiss() } + private fun updateBodySignature(signature: Signature) { + val selectedSignature = if (signature.isDummy) { + "''" // This will represent an empty string in js. + } else { + val signatureWithClass = signatureUtils.encapsulateSignatureContentWithInfomaniakClass(signature.content) + looselyEscapeAsStringLiteralForJs(signatureWithClass) + } + + val quotesSelector = ".${INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME},.${INFOMANIAK_FORWARD_QUOTE_HTML_CLASS_NAME}" + val replaceSignatureScript = replaceSignatureScript.format( + EDITOR_LOCAL_SIGNATURE_ID, + selectedSignature, + quotesSelector + ) + + binding.editorWebView.evaluateJavascript(replaceSignatureScript, null) + } + private fun updateSelectedSignatureInFromField(signature: Signature) { val defaultFormat = if (signature.senderName.isBlank()) { @@ -765,26 +747,6 @@ class NewMessageFragment : Fragment() { } } - private fun observeUiSignature() = with(binding) { - newMessageViewModel.uiSignatureLiveData.observe(viewLifecycleOwner) { signature -> - if (signature == null) { - signatureGroup.isGone = true - } else { - signatureWebView.loadSignatureContent(signature, signatureGroup) - } - } - } - - private fun observeUiQuote() = with(binding) { - newMessageViewModel.uiQuoteLiveData.observe(viewLifecycleOwner) { quote -> - if (quote == null) { - quoteGroup.isGone = true - } else { - quoteWebView.loadContent(quote, quoteGroup) - } - } - } - private fun observeShimmering() = lifecycleScope.launch { newMessageViewModel.isShimmering.collect(::setShimmerVisibility) } @@ -914,7 +876,7 @@ class NewMessageFragment : Fragment() { if (isTaskRoot) finishAndRemoveTask() else finish() } - fun navigateToPropositionFragment() = aiManager.navigateToPropositionFragment() + suspend fun navigateToPropositionFragment() = aiManager.navigateToPropositionFragment() fun closeAiPrompt() = aiManager.closeAiPrompt() diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageViewModel.kt index 93a7fccd522..a89d11d1a5a 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageViewModel.kt @@ -77,6 +77,7 @@ import com.infomaniak.mail.data.models.signature.Signature import com.infomaniak.mail.di.IoDispatcher import com.infomaniak.mail.di.MainDispatcher import com.infomaniak.mail.ui.main.SnackbarManager +import com.infomaniak.mail.ui.newMessage.EditorContentManager.Companion.toSanitizedHtml import com.infomaniak.mail.ui.newMessage.NewMessageActivity.DraftSaveConfiguration import com.infomaniak.mail.ui.newMessage.NewMessageEditorManager.EditorAction import com.infomaniak.mail.ui.newMessage.NewMessageRecipientFieldsManager.FieldType @@ -86,6 +87,10 @@ import com.infomaniak.mail.utils.DraftInitManager import com.infomaniak.mail.utils.JsoupParserUtil.jsoupParseWithLog import com.infomaniak.mail.utils.LocalStorageUtils import com.infomaniak.mail.utils.MessageBodyUtils +import com.infomaniak.mail.utils.MessageBodyUtils.EDITOR_LOCAL_SIGNATURE_ID +import com.infomaniak.mail.utils.MessageBodyUtils.INFOMANIAK_SIGNATURE_HTML_CLASS_NAME +import com.infomaniak.mail.utils.MessageBodyUtils.isHtmlBlank +import com.infomaniak.mail.utils.MessageBodyUtils.splitSignatureAndQuoteFromBody import com.infomaniak.mail.utils.SentryDebug import com.infomaniak.mail.utils.SharedUtils import com.infomaniak.mail.utils.SignatureUtils @@ -122,6 +127,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest @@ -161,8 +167,9 @@ class NewMessageViewModel @Inject constructor( //region Initial data private var initialBody: BodyContentPayload = BodyContentPayload.emptyBody() - private var initialSignature: String? = null - private var initialQuote: String? = null + private var initialSignature: BodyContentPayload? = null + // It is always sanitized to safely be inserted when the user clicks on "show quotes" and it's only set in initDraftAndViewModel + private var initialSanitizedQuote: String? = null //endregion //region UI data @@ -171,8 +178,6 @@ class NewMessageViewModel @Inject constructor( val ccLiveData = MutableLiveData() val bccLiveData = MutableLiveData() val attachmentsLiveData = MutableLiveData>() - val uiSignatureLiveData = MutableLiveData() - val uiQuoteLiveData = MutableLiveData() inline val allRecipients get() = toLiveData.valueOrEmpty() + ccLiveData.valueOrEmpty() + bccLiveData.valueOrEmpty() //endregion @@ -222,24 +227,32 @@ class NewMessageViewModel @Inject constructor( private val _isShimmering = MutableStateFlow(true) val isShimmering: StateFlow = _isShimmering + private val _isQuotesButtonVisible = MutableStateFlow(false) + val isQuotesButtonVisible: StateFlow = _isQuotesButtonVisible.asStateFlow() + + private var areQuotesIncluded = false + private val _quotesToIncludeChannel: Channel = Channel(capacity = CONFLATED) + val quotesToIncludeChannel: ReceiveChannel = _quotesToIncludeChannel + + private val _isPlaceHolderVisible = MutableStateFlow(false) + val isPlaceHolderVisible: StateFlow = _isPlaceHolderVisible.asStateFlow() + //region Check mailbox existence private val exitSignal: CompletableJob = Job() private val mailboxRefFlow = MutableSharedFlow( replay = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST + onBufferOverflow = BufferOverflow.DROP_OLDEST, ) - private val _currentMailboxFlow: Flow = mailboxRefFlow - .mapLatest { - val mailbox = mailboxController.getMailbox(it.userId, it.mailboxId) - if (mailbox == null) { - exitSignal.complete() - awaitCancellation() - } - mailbox + private val _currentMailboxFlow: Flow = mailboxRefFlow.mapLatest { + val mailbox = mailboxController.getMailbox(it.userId, it.mailboxId) + if (mailbox == null) { + exitSignal.complete() + awaitCancellation() } - .shareIn(viewModelScope, SharingStarted.Lazily, replay = 1) + mailbox + }.shareIn(viewModelScope, SharingStarted.Lazily, replay = 1) val currentUserIdFlow = _currentMailboxFlow .map { it.userId } @@ -321,30 +334,69 @@ class NewMessageViewModel @Inject constructor( }.getOrNull() draft?.let { - it.flagRecipientsAsAutomaticallyEntered() dismissNotification() markAsRead(currentMailbox(), realm) realm.write { DraftController.upsertDraftBlocking(it, realm = this) } - it.saveSnapshot(initialBody.content) it.initLiveData(signatures) - _isShimmering.emit(false) + processBodyAndInitializeEditorContent(it) + + _isShimmering.emit(false) initResult.postValue(InitResult(it, signatures)) } emit(draft) } + private fun processBodyAndInitializeEditorContent(draft: Draft) { + val sanitizedBody = initialBody.toSanitizedHtml() + initEditorElementsVisibility(sanitizedBody) + + val sanitizedSignature = initialSignature?.toSanitizedHtml() + val sanitizedBodyWithoutQuotes = sanitizedBody.mergeWithSignature(sanitizedSignature) + val sanitizedBodyContentWithoutQuotes = BodyContentPayload(sanitizedBodyWithoutQuotes, BodyContentType.HTML_SANITIZED) + /** + * We load the body into the editor without its quotes to improve performances. We load them only if the user expands the + * quotes section. Anyhow, the quotes will always be added before executing the draft action inside [addMissingQuotes]. + */ + editorBodyInitializer.postValue(sanitizedBodyContentWithoutQuotes) + + val sanitizedBodyWithQuotes = sanitizedBodyWithoutQuotes.mergeWithQuotes(initialSanitizedQuote) + + // We save the initial snapshot with its quotes, because quotes are added during snapshot comparison, ensuring it works + // whether the user included them or not. + draft.saveSnapshot(sanitizedBodyWithQuotes) + } + + private fun initEditorElementsVisibility(body: String) { + setPlaceholderVisibility(isVisible = body.isHtmlBlank()) + if (initialSanitizedQuote != null) setQuotesButtonVisibility(isVisible = true) + } + private suspend fun getExistingDraft(localUuid: String?): Draft? { + + fun String.sanitize(): String { + return BodyContentPayload(this, BodyContentType.HTML_UNSANITIZED).toSanitizedHtml() + } + return getLocalOrRemoteDraft(localUuid)?.also { draft -> saveNavArgsToSavedState(draft.localUuid) if (draft.identityId.isNullOrBlank()) { draft.identityId = currentMailbox().getDefaultSignatureWithFallback().id.toString() } - splitSignatureAndQuoteFromBody(draft) + + val (body, signature, quote) = splitSignatureAndQuoteFromBody(draft) + initialBody = body + if (signature != null) { + signature.getElementsByClass(INFOMANIAK_SIGNATURE_HTML_CLASS_NAME).attr("id", EDITOR_LOCAL_SIGNATURE_ID) + initialSignature = BodyContentPayload(signature.outerHtml(), BodyContentType.HTML_UNSANITIZED) + } + initialSanitizedQuote = quote?.outerHtml()?.sanitize() + + return draft } } @@ -366,31 +418,42 @@ class NewMessageViewModel @Inject constructor( if (hasFailedFetching) return@let - setReplyForwardDraftValues(draft = this, fullMessage) + initializeReplyForwardDraftValues(draft = this, fullMessage) + initialSanitizedQuote = draftInitManager.createQuote(draftMode, fullMessage, attachments)?.let { + BodyContentPayload(it, BodyContentType.HTML_UNSANITIZED).toSanitizedHtml() + } } } } + val signature: Signature with(draftInitManager) { - val signature = chooseSignature(currentMailbox().email, signatures, draftMode, previousMessage) + signature = chooseSignature(currentMailbox().email, signatures, draftMode, previousMessage) setSignatureIdentity(signature) - if (signature.content.isNotEmpty()) { - initialSignature = signatureUtils.encapsulateSignatureContentWithInfomaniakClass(signature.content) - } } + val encapsulatedSignature = signatureUtils.encapsulateSignatureContentWithInfomaniakClass(signature.content) + initialSignature = BodyContentPayload(encapsulatedSignature, BodyContentType.HTML_UNSANITIZED) populateWithExternalMailDataIfNeeded(draft = this, intent) } - private suspend fun setReplyForwardDraftValues(draft: Draft, fullMessage: Message) { + private fun String.mergeWithSignature(signature: String?): String { + return if (signature.isNullOrEmpty()) this else this + signature + } + + private fun String.mergeWithQuotes(quote: String?): String { + return if (quote.isNullOrEmpty()) this else this + quote + } + + /** + * Initializes some [draft] values related to reply, reply all and forward, when creating a new draft from scratch. + */ + private suspend fun initializeReplyForwardDraftValues(draft: Draft, fullMessage: Message) { with(draftInitManager) { draft.setPreviousMessage(draftMode = draftMode, previousMessage = fullMessage) } - val quote = draftInitManager.createQuote(draftMode, fullMessage, draft.attachments) - if (quote != null) initialQuote = quote - if (fullMessage.body == null) { SentryLog.e(TAG, "The message we're trying to reply to has an unexpected null body") { scope -> scope.setExtra("Message resource", fullMessage.resource) @@ -424,56 +487,6 @@ class NewMessageViewModel @Inject constructor( savedStateHandle[NewMessageActivityArgs::draftResource.name] = draftResource } - private fun splitSignatureAndQuoteFromBody(draft: Draft) { - val remoteBody = draft.body - if (remoteBody.isEmpty()) return - - val (body, signature, quote) = when (draft.mimeType) { - Utils.TEXT_PLAIN -> BodyData( - body = BodyContentPayload(remoteBody, BodyContentType.TEXT_PLAIN_WITHOUT_HTML), - signature = null, - quote = null - ) - Utils.TEXT_HTML -> splitSignatureAndQuoteFromHtml(remoteBody) - else -> error("Cannot load an email which is not of type text/plain or text/html") - } - - initialBody = body - initialSignature = signature - initialQuote = quote - } - - private fun splitSignatureAndQuoteFromHtml(draftBody: String): BodyData { - - fun Document.split(divClassName: String, defaultValue: String): Pair { - return getElementsByClass(divClassName).firstOrNull()?.let { - it.remove() - val first = body().html() - val second = if (it.html().isBlank()) null else it.outerHtml() - first to second - } ?: (defaultValue to null) - } - - fun String.lastIndexOfOrMax(string: String): Int { - val index = lastIndexOf(string) - return if (index == -1) Int.MAX_VALUE else index - } - - val doc = jsoupParseWithLog(draftBody).also { it.outputSettings().prettyPrint(false) } - - val (bodyWithQuote, signature) = doc.split(MessageBodyUtils.INFOMANIAK_SIGNATURE_HTML_CLASS_NAME, draftBody) - - val replyPosition = draftBody.lastIndexOfOrMax(MessageBodyUtils.INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME) - val forwardPosition = draftBody.lastIndexOfOrMax(MessageBodyUtils.INFOMANIAK_FORWARD_QUOTE_HTML_CLASS_NAME) - val (body, quote) = if (replyPosition < forwardPosition) { - doc.split(MessageBodyUtils.INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME, bodyWithQuote) - } else { - doc.split(MessageBodyUtils.INFOMANIAK_FORWARD_QUOTE_HTML_CLASS_NAME, bodyWithQuote) - } - - return BodyData(BodyContentPayload(body, BodyContentType.HTML_UNSANITIZED), signature, quote) - } - private suspend fun populateWithExternalMailDataIfNeeded(draft: Draft, intent: Intent) { when (intent.action) { Intent.ACTION_SEND -> handleSingleSendIntent(draft, intent) @@ -523,13 +536,21 @@ class NewMessageViewModel @Inject constructor( cc = cc.toSet(), bcc = bcc.toSet(), subject = subject, - uiBody = uiBodyValue, + uiBody = uiBodyValue.normalizeCarriageReturns(), isEncrypted = isEncrypted, encryptionPassword = encryptionKey ?: "", attachmentsLocalUuids = attachments.mapTo(mutableSetOf()) { it.localUuid }, ) } + /** + * The editor will always export code with `\n` even when we provided `\r\n` as input. To not fail snapshot comparison, we + * need to make sure we save the snapshots with `\n` only. + */ + private fun String.normalizeCarriageReturns(): String { + return replace("\r\n", "\n") + } + private suspend fun Draft.initLiveData(signatures: List) { val draftSignature = signatures.singleOrNull { it.id == identityId?.toInt() } @@ -548,11 +569,6 @@ class NewMessageViewModel @Inject constructor( attachmentsLiveData.postValue(attachments) - editorBodyInitializer.postValue(initialBody) - - uiSignatureLiveData.postValue(initialSignature) - uiQuoteLiveData.postValue(initialQuote) - if (cc.isNotEmpty() || bcc.isNotEmpty()) { otherRecipientsFieldsAreEmpty.postValue(false) initializeFieldsAsOpen.postValue(true) @@ -561,6 +577,19 @@ class NewMessageViewModel @Inject constructor( isEncryptionActivated.postValue(isEncrypted) } + fun setQuotesButtonVisibility(isVisible: Boolean) { + _isQuotesButtonVisible.value = isVisible + } + + fun includeQuotes() { + initialSanitizedQuote?.let { _quotesToIncludeChannel.trySend(it) } + areQuotesIncluded = true + } + + fun setPlaceholderVisibility(isVisible: Boolean) { + _isPlaceHolderVisible.value = isVisible + } + private suspend fun getLocalOrRemoteDraft(localUuid: String?): Draft? { @Suppress("UNUSED_PARAMETER") @@ -839,14 +868,6 @@ class NewMessageViewModel @Inject constructor( if (cc.isEmpty() && bcc.isEmpty()) otherRecipientsFieldsAreEmpty.value = true } - fun updateBodySignature(signature: Signature) { - uiSignatureLiveData.value = if (signature.isDummy) { - null - } else { - signatureUtils.encapsulateSignatureContentWithInfomaniakClass(signature.content) - } - } - fun uploadAttachmentsToServer(uiAttachments: List) = viewModelScope.launch(ioDispatcher) { val localUuid = draftLocalUuid ?: return@launch val realm = mailboxContentRealm() @@ -905,18 +926,27 @@ class NewMessageViewModel @Inject constructor( } } + private fun String.addMissingQuotes(): String { + return if (areQuotesIncluded || initialSanitizedQuote == null) { + this + } else { + this + initialSanitizedQuote + } + } + private suspend fun executeDraftAction(draftSaveConfiguration: DraftSaveConfiguration) = with(draftSaveConfiguration) { val localUuid = draftLocalUuid ?: return@with val subject = subjectValue.ifBlank { null }?.take(SUBJECT_MAX_LENGTH) - if (action == DraftAction.SAVE && isSnapshotTheSame(subject, uiBodyValue)) { + val bodyWithQuotes = uiBodyValue.addMissingQuotes() + if (action == DraftAction.SAVE && isSnapshotTheSame(subject, bodyWithQuotes)) { if (isFinishing && isNewMessage) removeDraftFromRealm(localUuid) return@with } val hasFailed = mailboxContentRealm().write { DraftController.getDraftBlocking(localUuid, realm = this) - ?.updateDraftBeforeSavingRemotely(action, isFinishing, subject, uiBodyValue, realm = this@write) + ?.updateDraftBeforeSavingRemotely(action, isFinishing, subject, bodyWithQuotes, realm = this@write) ?: return@write true return@write false } @@ -964,8 +994,6 @@ class NewMessageViewModel @Inject constructor( subject = subjectValue - body = uiBodyValue + (uiSignatureLiveData.value ?: "") + (uiQuoteLiveData.value ?: "") - /** * If we are opening for the 1st time an existing Draft created somewhere else * (ex: webmail), we need to create the link between the Draft and its Message. @@ -983,6 +1011,31 @@ class NewMessageViewModel @Inject constructor( copyFromRealm().saveSnapshot(uiBodyValue) isNewMessage = false } + + body = removeUnwantedHtml(uiBodyValue) + } + + /** + * We filter out some HTML elements that we don't want to send with the email. + */ + private fun removeUnwantedHtml(html: String): String { + val doc = jsoupParseWithLog(html) + // This assures that jsoup doesn't add line breaks that will impact the snapshot comparison + doc.outputSettings().prettyPrint(false) + + // Remove id used for replacing signature. + doc.getElementById(EDITOR_LOCAL_SIGNATURE_ID)?.removeAttr("id") + + // If the user deleted the quotes' text, remove the quotes' div so user doesn't write in it + // (the text could get hidden later with the show quotes button). + doc.removeEmptyElements(MessageBodyUtils.INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME) + doc.removeEmptyElements(MessageBodyUtils.INFOMANIAK_FORWARD_QUOTE_HTML_CLASS_NAME) + return doc.html() + } + + private fun Document.removeEmptyElements(className: String) { + val elements = getElementsByClass(className) + elements.forEach { if (it.text().isEmpty()) it.remove() } } private fun Draft.updateDraftAttachmentsWithLiveData(uiAttachments: List, step: String) { @@ -1087,6 +1140,13 @@ class NewMessageViewModel @Inject constructor( ) } + fun deleteInlineAttachments(cids: List) { + val cidsToDelete = cids.map { it.removePrefix("cid:") } + val attachmentsData = attachmentsLiveData.value ?: return + val newAttachments = attachmentsData.filter { attachment -> !cidsToDelete.contains(attachment.contentId) } + attachmentsLiveData.postValue(newAttachments) + } + enum class ImportationResult { SUCCESS, ATTACHMENTS_TOO_BIG, @@ -1123,8 +1183,6 @@ class NewMessageViewModel @Inject constructor( private data class SubjectAndBodyData(val subject: String, val body: String, val expirationId: Int) - private data class BodyData(val body: BodyContentPayload, val signature: String?, val quote: String?) - companion object { private val TAG = NewMessageViewModel::class.java.simpleName diff --git a/app/src/main/java/com/infomaniak/mail/utils/HtmlFormatter.kt b/app/src/main/java/com/infomaniak/mail/utils/HtmlFormatter.kt index 85e7c24cf22..0960462b487 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/HtmlFormatter.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/HtmlFormatter.kt @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2023-2025 Infomaniak Network SA + * Copyright (C) 2023-2026 Infomaniak Network SA * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -214,12 +214,17 @@ class HtmlFormatter(private val html: String) { fun Context.getImproveRenderingStyle(): String = loadCss(R.raw.improve_rendering) + fun Context.getMessageDisplayStyle(): String = loadCss(R.raw.message_display_style) + fun Context.getCustomStyle(): String = loadCss( R.raw.style, listOf(PRIMARY_COLOR_CODE to getAttributeColor(RAndroid.attr.colorPrimary)), ) - fun Context.getSignatureMarginStyle(): String = loadCss(R.raw.signature_margins) + fun Context.getCustomEditorStyle(): String = loadCss( + R.raw.editor_style, + listOf(PRIMARY_COLOR_CODE to getAttributeColor(RAndroid.attr.colorPrimary)) + ) fun Context.getPrintMailStyle(): String = loadCss(R.raw.print_email) @@ -228,12 +233,30 @@ class HtmlFormatter(private val html: String) { listOf("MESSAGE_SELECTOR" to "#$KMAIL_MESSAGE_ID") ) + fun Context.getIncludeQuotesScript(): String = loadScript(R.raw.include_quotes_script) + + fun Context.getDeletedInlineImagesObserverScript(): String = loadScript(R.raw.deleted_inline_images_observer) + + fun Context.getReplaceSignatureScript(): String = loadScript(R.raw.replace_signature_script) + fun Context.getFixStyleScript(): String { return loadScript(R.raw.fix_email_style) } - fun Context.getJsBridgeScript(): String { - return loadScript(R.raw.javascript_bridge) + fun Context.getMessageDisplayJavascriptBridge(): String { + return loadScript(R.raw.message_display_javascript_bridge) + } + + fun Context.getEditorJsBridgeScript(): String { + return loadScript(R.raw.editor_javascript_bridge) + } + + fun Context.getCheckIsEditorBodyEmptyScript(): String { + return loadScript(R.raw.check_is_editor_body_empty) + } + + fun Context.getSetAiContentScript(): String { + return loadScript(R.raw.set_ai_content_script) } } } diff --git a/app/src/main/java/com/infomaniak/mail/utils/HtmlUtils.kt b/app/src/main/java/com/infomaniak/mail/utils/HtmlUtils.kt index ca6bd471f37..49e74c64cf3 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/HtmlUtils.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/HtmlUtils.kt @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2024 Infomaniak Network SA + * Copyright (C) 2024-2026 Infomaniak Network SA * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,7 +18,7 @@ package com.infomaniak.mail.utils import com.infomaniak.mail.data.models.Attachment -import com.infomaniak.mail.ui.main.thread.MessageWebViewClient +import com.infomaniak.mail.ui.main.thread.webViewClient.MessageWebViewClient import org.jsoup.nodes.Document import org.jsoup.nodes.Element diff --git a/app/src/main/java/com/infomaniak/mail/utils/MessageBodyUtils.kt b/app/src/main/java/com/infomaniak/mail/utils/MessageBodyUtils.kt index 85b9f1b2692..08d2ff390ce 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/MessageBodyUtils.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/MessageBodyUtils.kt @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2023-2024 Infomaniak Network SA + * Copyright (C) 2023-2026 Infomaniak Network SA * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,23 +18,30 @@ package com.infomaniak.mail.utils import android.content.Context +import com.infomaniak.mail.data.models.draft.Draft import com.infomaniak.mail.data.models.message.Body import com.infomaniak.mail.data.models.message.Message import com.infomaniak.mail.data.models.message.SubBody +import com.infomaniak.mail.ui.newMessage.BodyContentPayload +import com.infomaniak.mail.ui.newMessage.BodyContentType import com.infomaniak.mail.utils.JsoupParserUtil.jsoupParseWithLog import com.infomaniak.mail.utils.JsoupParserUtil.measureAndLogMemoryUsage import com.infomaniak.mail.utils.PrintHeaderUtils.createPrintHeader +import com.infomaniak.mail.utils.extensions.htmlToText import io.sentry.Sentry import io.sentry.SentryLevel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ensureActive import org.jsoup.nodes.Document +import org.jsoup.nodes.Element object MessageBodyUtils { const val INFOMANIAK_SIGNATURE_HTML_CLASS_NAME = "editorUserSignature" const val INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME = "ik_mail_quote" const val INFOMANIAK_FORWARD_QUOTE_HTML_CLASS_NAME = "forwardContentMessage" + // This ID is used for finding the signature to replace it. REMOVE before sending the email. + const val EDITOR_LOCAL_SIGNATURE_ID = "ikEditorLocalSignature" private const val QUOTE_DETECTION_TIMEOUT = 1_500L @@ -63,6 +70,7 @@ object MessageBodyUtils { "[name=\"quote\"]", // GMX ) + //region Split suspend fun splitContentAndQuote(body: Body): SplitBody { val bodyContent = body.value @@ -113,13 +121,69 @@ object MessageBodyUtils { return htmlDocument.outerHtml() to quotes } - fun addPrintHeader(context: Context, message: Message, htmlDocument: Document) { - htmlDocument.body().apply { - attr("style", "margin: 40px") - insertChildren(0, createPrintHeader(context, message)) + fun String.isHtmlBlank(): Boolean { + return htmlToText().isBlank() + } + + private fun splitSignatureAndQuoteFromHtml(body: String): BodyData { + val doc = jsoupParseWithLog(body) + return splitSignatureAndQuoteFromHtml(doc) + } + + /** + * This only takes into account infomaniak's quotes and signatures. + */ + fun splitSignatureAndQuoteFromHtml(document: Document): BodyData { + + fun Document.split(divClassName: String, defaultValue: Element): Pair { + // We need to use select and not getElementByClass because getElementByClass adds a \n after every
. + // The function remove() also adds \n after each
of it. + // So we need to use select, save the second part of the split first, and then do remove, to save the correct value + // for the snapshot to compare. + return select(".$divClassName").firstOrNull()?.let { + val second = if (it.html().isBlank()) null else it + it.remove() + document to second + } ?: (defaultValue to null) + } + + document.outputSettings().prettyPrint(false) + + val (bodyWithQuote, signature) = document.split(INFOMANIAK_SIGNATURE_HTML_CLASS_NAME, document) + + val replyPosition = document.getElementPositionOrMax(".$INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME") + val forwardPosition = document.getElementPositionOrMax(".$INFOMANIAK_FORWARD_QUOTE_HTML_CLASS_NAME") + val (body, quote) = if (replyPosition < forwardPosition) { + document.split(INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME, bodyWithQuote) + } else { + document.split(INFOMANIAK_FORWARD_QUOTE_HTML_CLASS_NAME, bodyWithQuote) + } + + return BodyData(BodyContentPayload(body.html(), BodyContentType.HTML_UNSANITIZED), signature, quote) + } + + fun splitSignatureAndQuoteFromBody(draft: Draft): BodyData { + val remoteBody = draft.body + if (remoteBody.isEmpty()) return BodyData(BodyContentPayload.emptyBody(), null, null) + + val (body, signature, quote) = when (draft.mimeType) { + Utils.TEXT_PLAIN -> BodyData( + body = BodyContentPayload(remoteBody, BodyContentType.TEXT_PLAIN_WITHOUT_HTML), + signature = null, + quote = null + ) + Utils.TEXT_HTML -> splitSignatureAndQuoteFromHtml(remoteBody) + else -> error("Cannot load an email which is not of type text/plain or text/html") } + + return BodyData(body, signature, quote) } + private fun Document.getElementPositionOrMax(className: String): Int { + return this.selectFirst(className)?.sourceRange()?.start()?.pos() ?: Int.MAX_VALUE + } + //endregion + fun mergeSplitBodyAndSubBodies(body: String, subBodies: List): String { return body + formatSubBodiesContent(subBodies) } @@ -137,6 +201,13 @@ object MessageBodyUtils { return subBodiesContent } + fun addPrintHeader(context: Context, message: Message, htmlDocument: Document) { + htmlDocument.body().apply { + attr("style", "margin: 40px") + insertChildren(0, createPrintHeader(context, message)) + } + } + //region Utils /** * Some Email clients rename CSS classes to prefix them. @@ -156,4 +227,6 @@ object MessageBodyUtils { override fun hashCode(): Int = 31 * content.hashCode() + quote.hashCode() } + + data class BodyData(val body: BodyContentPayload, val signature: Element?, val quote: Element?) } diff --git a/app/src/main/java/com/infomaniak/mail/utils/SignatureUtils.kt b/app/src/main/java/com/infomaniak/mail/utils/SignatureUtils.kt index 3528eee9a84..e0bcd89f6fd 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/SignatureUtils.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/SignatureUtils.kt @@ -17,24 +17,14 @@ */ package com.infomaniak.mail.utils -import android.content.Context -import com.infomaniak.mail.R -import com.infomaniak.mail.utils.extensions.loadCss +import com.infomaniak.mail.utils.MessageBodyUtils.EDITOR_LOCAL_SIGNATURE_ID +import com.infomaniak.mail.utils.MessageBodyUtils.INFOMANIAK_SIGNATURE_HTML_CLASS_NAME import javax.inject.Inject import javax.inject.Singleton @Singleton -class SignatureUtils @Inject constructor(appContext: Context) { - - private val signatureMargins by lazy { appContext.loadCss(R.raw.signature_margins) } - +class SignatureUtils @Inject constructor() { fun encapsulateSignatureContentWithInfomaniakClass(signatureContent: String): String { - val verticalMarginsCss = signatureMargins - val verticalMarginAttributes = extractAttributesFromMarginCss(verticalMarginsCss) - return """
$signatureContent
""" - } - - private fun extractAttributesFromMarginCss(verticalMarginsCss: String): String { - return Regex("""\{(.*)\}""").find(verticalMarginsCss)!!.groupValues[1] + return """
$signatureContent
""" } } diff --git a/app/src/main/java/com/infomaniak/mail/utils/WebViewUtils.kt b/app/src/main/java/com/infomaniak/mail/utils/WebViewUtils.kt index de1cb26180e..5dede462ae7 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/WebViewUtils.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/WebViewUtils.kt @@ -21,21 +21,24 @@ import android.annotation.SuppressLint import android.content.Context import android.view.MotionEvent import android.view.ViewParent -import android.webkit.JavascriptInterface import android.webkit.WebSettings import android.webkit.WebSettings.LOAD_CACHE_ELSE_NETWORK import android.webkit.WebView import com.infomaniak.mail.R +import com.infomaniak.mail.data.models.javascriptBridge.EditorJavascriptBridge +import com.infomaniak.mail.data.models.javascriptBridge.MessageDisplayJavascriptBridge import com.infomaniak.mail.utils.HtmlFormatter.Companion.getCustomDarkMode import com.infomaniak.mail.utils.HtmlFormatter.Companion.getCustomStyle import com.infomaniak.mail.utils.HtmlFormatter.Companion.getFixStyleScript import com.infomaniak.mail.utils.HtmlFormatter.Companion.getImproveRenderingStyle -import com.infomaniak.mail.utils.HtmlFormatter.Companion.getJsBridgeScript +import com.infomaniak.mail.utils.HtmlFormatter.Companion.getMessageDisplayJavascriptBridge +import com.infomaniak.mail.utils.HtmlFormatter.Companion.getMessageDisplayStyle import com.infomaniak.mail.utils.HtmlFormatter.Companion.getPrintMailStyle import com.infomaniak.mail.utils.HtmlFormatter.Companion.getResizeScript -import com.infomaniak.mail.utils.HtmlFormatter.Companion.getSignatureMarginStyle import com.infomaniak.mail.utils.extensions.enableAlgorithmicDarkening import com.infomaniak.mail.utils.extensions.loadCss +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume import kotlin.math.abs class WebViewUtils(context: Context) { @@ -43,12 +46,12 @@ class WebViewUtils(context: Context) { private val customDarkMode by lazy { context.getCustomDarkMode() } private val improveRenderingStyle by lazy { context.getImproveRenderingStyle() } private val customStyle by lazy { context.getCustomStyle() } - private val signatureVerticalMargin by lazy { context.getSignatureMarginStyle() } + private val messageDisplayStyle by lazy { context.getMessageDisplayStyle() } private val printMailStyle by lazy { context.getPrintMailStyle() } private val resizeScript by lazy { context.getResizeScript() } private val fixStyleScript by lazy { context.getFixStyleScript() } - private val jsBridgeScript by lazy { context.getJsBridgeScript() } + private val messageDisplayJsBridgeScript by lazy { context.getMessageDisplayJavascriptBridge() } fun processHtmlForPrint( html: String, @@ -68,85 +71,44 @@ class WebViewUtils(context: Context) { return@with inject() } - fun processSignatureHtmlForDisplay( - html: String, - isDisplayedInDarkMode: Boolean, - ): String = with(HtmlFormatter(html)) { - addCommonDisplayContent(isDisplayedInDarkMode) - registerCss(signatureVerticalMargin) - return@with inject() - } - private fun HtmlFormatter.addCommonDisplayContent(isDisplayedInDarkMode: Boolean) { if (isDisplayedInDarkMode) registerCss(customDarkMode, DARK_BACKGROUND_STYLE_ID) registerCss(improveRenderingStyle) registerCss(customStyle) + registerCss(messageDisplayStyle) registerMetaViewPort() registerScript(resizeScript) registerScript(fixStyleScript) - registerScript(jsBridgeScript) + registerScript(messageDisplayJsBridgeScript) registerBodyEncapsulation() registerBreakLongWords() } - class JavascriptBridge(private val onWebViewFinishedLoading: (() -> Unit)? = null) { - - @JavascriptInterface - fun reportOverScroll(clientWidth: Int, scrollWidth: Int, messageUid: String) { - SentryDebug.sendOverScrolledMessage(clientWidth, scrollWidth, messageUid) - } - - @JavascriptInterface - fun reportError( - errorName: String, - errorMessage: String, - errorStack: String, - scriptFirstLine: String, - messageUid: String, - ) { - val correctErrorStack = fixStackTraceLineNumber(errorStack, scriptFirstLine) - SentryDebug.sendJavaScriptError(errorName, errorMessage, correctErrorStack, messageUid) - } - - @JavascriptInterface - fun webviewFinishedLoading() { - onWebViewFinishedLoading?.invoke() - } - - private fun fixStackTraceLineNumber(errorStack: String, scriptFirstLine: String): String { - var correctErrorStack = errorStack - val matches = "about:blank:([0-9]+):".toRegex().findAll(correctErrorStack) - matches.forEach { match -> - val lineNumber = match.groupValues[1] - val newLineNumber = lineNumber.toInt() - scriptFirstLine.toInt() + 1 - correctErrorStack = correctErrorStack.replace(match.groupValues[0], "about:blank:$newLineNumber:") - } - return correctErrorStack - } - } - companion object { private const val DARK_BACKGROUND_STYLE_ID = "dark_background_style" - lateinit var jsBridge: JavascriptBridge // TODO: Avoid excessive memory consumption with injection + lateinit var messageDisplayJsBridge: MessageDisplayJavascriptBridge // TODO: Avoid excessive memory consumption with injection + lateinit var editorJsBridge: EditorJavascriptBridge - fun initJavascriptBridge(onWebViewFinishedLoading: (() -> Unit)? = null) { - jsBridge = JavascriptBridge(onWebViewFinishedLoading = onWebViewFinishedLoading) + fun initMessageDisplayJavascriptBridge(onWebViewFinishedLoading: () -> Unit) { + messageDisplayJsBridge = MessageDisplayJavascriptBridge(onWebViewFinishedLoading = onWebViewFinishedLoading) + } + + fun initEditorJsBridge(onInlineImagesDeleted: (List) -> Unit) { + editorJsBridge = EditorJavascriptBridge(onInlineImagesDeleted = onInlineImagesDeleted) } private fun WebSettings.setupCommonWebViewSettings() { @SuppressLint("SetJavaScriptEnabled") javaScriptEnabled = true - - loadWithOverviewMode = true - useWideViewPort = true - cacheMode = LOAD_CACHE_ELSE_NETWORK } fun WebSettings.setupThreadWebViewSettings() { setupCommonWebViewSettings() + useWideViewPort = true + loadWithOverviewMode = true setSupportZoom(true) builtInZoomControls = true displayZoomControls = false @@ -178,9 +140,10 @@ class WebViewUtils(context: Context) { evaluateJavascript(removeBackgroundStyleScript, null) } - fun WebView.destroyAndClearHistory() { - clearHistory() - destroy() + suspend fun WebView.evaluateJs(script: String): String = suspendCancellableCoroutine { continuation -> + evaluateJavascript(script) { + continuation.resume(it) + } } /** diff --git a/app/src/main/java/com/infomaniak/mail/utils/extensions/Extensions.kt b/app/src/main/java/com/infomaniak/mail/utils/extensions/Extensions.kt index b70f87b6126..48d57478f39 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/extensions/Extensions.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/extensions/Extensions.kt @@ -101,12 +101,13 @@ import com.infomaniak.mail.ui.main.folder.DateSeparatorItemDecoration import com.infomaniak.mail.ui.main.folder.HeaderItemDecoration import com.infomaniak.mail.ui.main.folder.ThreadListAdapter import com.infomaniak.mail.ui.main.folder.ThreadListItem -import com.infomaniak.mail.ui.main.thread.MessageWebViewClient import com.infomaniak.mail.ui.main.thread.RoundedBackgroundSpan import com.infomaniak.mail.ui.main.thread.SubjectFormatter.Companion.getTagsPaint import com.infomaniak.mail.ui.main.thread.SubjectFormatter.EllipsizeConfiguration import com.infomaniak.mail.ui.main.thread.SubjectFormatter.TagColor import com.infomaniak.mail.ui.main.thread.ThreadFragment.HeaderState +import com.infomaniak.mail.ui.main.thread.webViewClient.EditorWebViewClient +import com.infomaniak.mail.ui.main.thread.webViewClient.MessageDisplayWebViewClient import com.infomaniak.mail.ui.newMessage.NewMessageViewModel.UiRecipients import com.infomaniak.mail.utils.AccountUtils import com.infomaniak.mail.utils.ApiErrorException @@ -257,29 +258,25 @@ fun LottieAnimationView.changePathColor(illuColors: IlluColors) { SimpleColorFilter(illuColors.color) } } +//endregion -fun WebView.initWebViewClientAndBridge( +//region WebViewClient +fun WebView.initDisplayWebViewClientAndBridge( attachments: List, messageUid: String, shouldLoadDistantResources: Boolean, - onBlockedResourcesDetected: (() -> Unit)? = null, + onBlockedResourcesDetected: () -> Unit, navigateToNewMessageActivity: ((Uri) -> Unit)?, onPageFinished: (() -> Unit)? = null, - onWebViewFinishedLoading: (() -> Unit)? = null, -): MessageWebViewClient { + onWebViewFinishedLoading: () -> Unit, +): MessageDisplayWebViewClient { - WebViewUtils.initJavascriptBridge(onWebViewFinishedLoading) - addJavascriptInterface(WebViewUtils.jsBridge, "kmail") + WebViewUtils.initMessageDisplayJavascriptBridge(onWebViewFinishedLoading) + addJavascriptInterface(WebViewUtils.messageDisplayJsBridge, "kmail") - val cidDictionary = mutableMapOf().apply { - attachments.forEach { - it.contentId?.let { cid -> - if (cid.isNotBlank()) this[cid] = it - } - } - } + val cidDictionary = attachments.toCidDictionary() - return MessageWebViewClient( + return MessageDisplayWebViewClient( context, cidDictionary, messageUid, @@ -291,6 +288,38 @@ fun WebView.initWebViewClientAndBridge( webViewClient = it } } + +fun WebView.initEditorWebviewBridge(onInlineImagesDeleted: (List) -> Unit) { + WebViewUtils.initEditorJsBridge(onInlineImagesDeleted) + addJavascriptInterface(WebViewUtils.editorJsBridge, "kmail") +} + +fun WebView.initEditorWebviewClient( + attachments: List, + shouldLoadDistantResources: Boolean, + onPageFinished: () -> Unit, +): EditorWebViewClient { + val cidDictionary = attachments.toCidDictionary() + + return EditorWebViewClient( + context, + cidDictionary, + shouldLoadDistantResources, + onPageFinished, + ).also { + webViewClient = it + } +} + +private fun List.toCidDictionary(): Map { + return buildMap { + forEach { + it.contentId?.let { cid -> + if (cid.isNotBlank()) this[cid] = it + } + } + } +} //endregion //region API diff --git a/app/src/main/res/layout/fragment_new_message.xml b/app/src/main/res/layout/fragment_new_message.xml index ea4ba1bde7c..849db3403e1 100644 --- a/app/src/main/res/layout/fragment_new_message.xml +++ b/app/src/main/res/layout/fragment_new_message.xml @@ -312,13 +312,23 @@ android:text="@string/newMessagePlaceholderTitle" android:textColor="@color/tertiaryTextColor" android:visibility="gone" - app:layout_constraintBottom_toBottomOf="@id/editorWebView" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="@id/editorWebView" tools:visibility="visible" /> + + - - - - - - - - - - - - -