/* * Copyright (c) 2022 Proton AG * * This file is part of Proton Mail. * * Proton Mail 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. * * Proton Mail 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 Proton Mail. If not, see https://www.gnu.org/licenses/. */ package ch.protonmail.android.activities.messageDetails.viewmodel import android.annotation.TargetApi import android.content.ActivityNotFoundException import android.content.Context import android.graphics.Color import android.net.Uri import android.os.Build import android.os.Environment import android.print.PrintManager import androidx.core.content.FileProvider import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.OnLifecycleEvent import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import ch.protonmail.android.R import ch.protonmail.android.activities.messageDetails.IntentExtrasData import ch.protonmail.android.activities.messageDetails.MessagePrinter import ch.protonmail.android.activities.messageDetails.MessageRenderer import ch.protonmail.android.activities.messageDetails.repository.MessageDetailsRepository import ch.protonmail.android.api.NetworkConfigurator import ch.protonmail.android.api.models.User import ch.protonmail.android.attachments.AttachmentsHelper import ch.protonmail.android.attachments.DownloadEmbeddedAttachmentsWorker import ch.protonmail.android.core.BigContentHolder import ch.protonmail.android.core.Constants import ch.protonmail.android.core.Constants.DIR_EMB_ATTACHMENT_DOWNLOADS import ch.protonmail.android.core.UserManager import ch.protonmail.android.data.ContactsRepository import ch.protonmail.android.data.local.AttachmentMetadataDao import ch.protonmail.android.data.local.model.Attachment import ch.protonmail.android.data.local.model.Message import ch.protonmail.android.details.data.toConversationUiModel import ch.protonmail.android.details.domain.usecase.GetViewInDarkModeMessagePreference import ch.protonmail.android.details.presentation.model.ConversationUiModel import ch.protonmail.android.details.presentation.model.MessageBodyState import ch.protonmail.android.details.presentation.ui.MessageDetailsActivity import ch.protonmail.android.domain.entity.EmailAddress import ch.protonmail.android.domain.entity.Name import ch.protonmail.android.events.DownloadEmbeddedImagesEvent import ch.protonmail.android.events.Status import ch.protonmail.android.jobs.ReportPhishingJob import ch.protonmail.android.jobs.helper.EmbeddedImage import ch.protonmail.android.labels.domain.LabelRepository import ch.protonmail.android.labels.domain.model.Label import ch.protonmail.android.labels.domain.model.LabelId import ch.protonmail.android.labels.domain.model.LabelType import ch.protonmail.android.mailbox.domain.ChangeConversationsReadStatus import ch.protonmail.android.mailbox.domain.ChangeConversationsStarredStatus import ch.protonmail.android.mailbox.domain.ConversationsRepository import ch.protonmail.android.mailbox.domain.DeleteConversations import ch.protonmail.android.mailbox.domain.MoveConversationsToFolder import ch.protonmail.android.mailbox.domain.model.Conversation import ch.protonmail.android.mailbox.domain.usecase.MoveMessagesToFolder import ch.protonmail.android.mailbox.presentation.util.ConversationModeEnabled import ch.protonmail.android.repository.MessageRepository import ch.protonmail.android.ui.model.LabelChipUiModel import ch.protonmail.android.usecase.IsAppInDarkMode import ch.protonmail.android.usecase.VerifyConnection import ch.protonmail.android.usecase.delete.DeleteMessage import ch.protonmail.android.usecase.fetch.FetchVerificationKeys import ch.protonmail.android.usecase.message.ChangeMessagesReadStatus import ch.protonmail.android.usecase.message.ChangeMessagesStarredStatus import ch.protonmail.android.util.ProtonCalendarUtil import ch.protonmail.android.utils.AppUtil import ch.protonmail.android.utils.DownloadUtils import ch.protonmail.android.utils.Event import ch.protonmail.android.utils.HTMLTransformer.DefaultTransformer import ch.protonmail.android.utils.HTMLTransformer.ViewportTransformer import ch.protonmail.android.utils.UiUtil import ch.protonmail.android.utils.crypto.KeyInformation import ch.protonmail.android.viewmodel.ConnectivityBaseViewModel import com.birbit.android.jobqueue.JobManager import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import me.proton.core.domain.arch.DataResult import me.proton.core.domain.entity.UserId import me.proton.core.util.kotlin.DispatcherProvider import me.proton.core.util.kotlin.mapSecond import me.proton.core.util.kotlin.takeIfNotBlank import okio.buffer import okio.sink import okio.source import org.jsoup.Jsoup import timber.log.Timber import java.io.File import java.io.IOException import javax.inject.Inject @Suppress("LongParameterList") // Every new parameter adds a new issue and breaks the build @HiltViewModel internal class MessageDetailsViewModel @Inject constructor( private val isAppInDarkMode: IsAppInDarkMode, private val getViewInDarkModeMessagePreference: GetViewInDarkModeMessagePreference, private val messageDetailsRepository: MessageDetailsRepository, private val messageRepository: MessageRepository, private val userManager: UserManager, private val contactsRepository: ContactsRepository, private val labelRepository: LabelRepository, private val attachmentMetadataDao: AttachmentMetadataDao, private val fetchVerificationKeys: FetchVerificationKeys, private val attachmentsWorker: DownloadEmbeddedAttachmentsWorker.Enqueuer, private val dispatchers: DispatcherProvider, private val attachmentsHelper: AttachmentsHelper, private val downloadUtils: DownloadUtils, private val moveMessagesToFolder: MoveMessagesToFolder, private val moveConversationsToFolder: MoveConversationsToFolder, private val conversationModeEnabled: ConversationModeEnabled, private val conversationRepository: ConversationsRepository, private val changeMessagesReadStatus: ChangeMessagesReadStatus, private val changeConversationsReadStatus: ChangeConversationsReadStatus, private val changeMessagesStarredStatus: ChangeMessagesStarredStatus, private val changeConversationsStarredStatus: ChangeConversationsStarredStatus, private val deleteMessage: DeleteMessage, private val deleteConversations: DeleteConversations, private val savedStateHandle: SavedStateHandle, messageRendererFactory: MessageRenderer.Factory, verifyConnection: VerifyConnection, networkConfigurator: NetworkConfigurator, private val protonCalendarUtil: ProtonCalendarUtil ) : ConnectivityBaseViewModel(verifyConnection, networkConfigurator), LifecycleObserver { private val messageOrConversationId: String = savedStateHandle.get(MessageDetailsActivity.EXTRA_MESSAGE_OR_CONVERSATION_ID) ?: throw IllegalStateException("messageId in MessageDetails is Empty!") private val location: Constants.MessageLocationType get() = Constants.MessageLocationType.fromInt( savedStateHandle.get(MessageDetailsActivity.EXTRA_MESSAGE_LOCATION_ID) ?: Constants.MessageLocationType.INVALID.messageLocationTypeValue ) private val mailboxLocationId: String? by lazy { savedStateHandle.get(MessageDetailsActivity.EXTRA_MAILBOX_LABEL_ID) } private val messageRenderer by lazy { messageRendererFactory.create(viewModelScope) } var renderingPassed = false var hasEmbeddedImages: Boolean = false private var embeddedImagesAttachments: ArrayList = ArrayList() private var embeddedImagesToFetch: ArrayList = ArrayList() private var remoteContentDisplayed: Boolean = false private var calendarAttachmentId: String? = null private val _prepareEditMessageIntentResult: MutableLiveData> = MutableLiveData() private val _decryptedConversationUiModel: MutableLiveData = MutableLiveData() private val _messageRenderedWithImages: MutableLiveData = MutableLiveData() private val _checkStoragePermission: MutableLiveData> = MutableLiveData() private val _messageDetailsError: MutableLiveData> = MutableLiveData() private val _showPermissionMissingDialog: MutableLiveData = MutableLiveData() private val _conversationUiFlow = MutableSharedFlow(replay = 1) private val _reloadMessageFlow = MutableSharedFlow(replay = 1) val conversationUiModel: SharedFlow get() = _conversationUiFlow val checkStoragePermission: LiveData> get() = _checkStoragePermission val messageDetailsError: LiveData> get() = _messageDetailsError val showPermissionMissingDialog: LiveData get() = _showPermissionMissingDialog val prepareEditMessageIntent: LiveData> get() = _prepareEditMessageIntentResult val decryptedConversationUiModel: LiveData get() = _decryptedConversationUiModel val messageRenderedWithImages: LiveData get() = _messageRenderedWithImages val reloadMessageFlow: SharedFlow get() = _reloadMessageFlow private var areImagesDisplayed: Boolean = false private var visibleToTheUser = true private var conversationFlowJob: Job? = null init { // message render flow conversationFlowJob = userManager.primaryUserId .filterNotNull() .flatMapLatest { userId -> if (isConversationEnabled()) { getConversationFlow(userId) } else { getMessageFlow(userId) } } .filterNotNull() .distinctUntilChanged() .combineWithLabels() .onEach { Timber.i("Emit conversation Ui model subject ${it.subject}") emitConversationUiItem(it) } .launchIn(viewModelScope) } private fun getMessageFlow(userId: UserId): Flow = messageRepository.observeMessage(userId, messageOrConversationId) .distinctUntilChanged() .map { loadMessageDetails(it) } private fun getConversationFlow(userId: UserId): Flow = conversationRepository.getConversation(userId, messageOrConversationId) .distinctUntilChanged() .filterOutIncompleteConversations() .map { loadConversationDetails(it, userId) } private fun Flow>.filterOutIncompleteConversations() = filterNot { result -> result is DataResult.Success && !result.value.isComplete() } private fun Flow.combineWithLabels() = flatMapLatest { conversation -> val nonExclusiveLabelsHashMap = hashMapOf>() val exclusiveLabelsHashMap = hashMapOf>() conversation.messages.filter { it.allLabelIDs.isNotEmpty() }.forEach { message -> val messageId = requireNotNull(message.messageId) getAllLabelsFor(message)?.let { (exclusiveLabels, nonExclusiveLabels) -> exclusiveLabelsHashMap[messageId] = exclusiveLabels.toList() nonExclusiveLabelsHashMap[messageId] = nonExclusiveLabels } } return@flatMapLatest flowOf( conversation.copy( nonExclusiveLabels = nonExclusiveLabelsHashMap, exclusiveLabels = exclusiveLabelsHashMap ) ) } private suspend fun getAllLabelsFor( message: Message ): Pair, List>? { val allLabelIds = message.allLabelIDs.map { labelId -> LabelId(labelId) } return labelRepository.observeLabels(allLabelIds) .firstOrNull() ?.partition { it.type == LabelType.FOLDER } ?.mapSecond { it.toNonExclusiveLabelModel() } } private fun Label.toNonExclusiveLabelModel(): LabelChipUiModel { val labelColor = color.takeIfNotBlank() ?.let { Color.parseColor(UiUtil.normalizeColor(it)) } return LabelChipUiModel(id, Name(name), labelColor) } fun cancelConversationFlowJob() = conversationFlowJob?.cancel() fun markUnread() { viewModelScope.launch { if (isConversationEnabled()) { changeConversationsReadStatus( listOf(messageOrConversationId), ChangeConversationsReadStatus.Action.ACTION_MARK_UNREAD, userManager.requireCurrentUserId(), mailboxLocationId ?: location.messageLocationTypeValue.toString() ) } else { changeMessagesReadStatus( listOf(messageOrConversationId), ChangeMessagesReadStatus.Action.ACTION_MARK_UNREAD, userManager.requireCurrentUserId() ) } } } fun loadMessageBody(message: Message) = flow { Timber.v("loadMessageBody ${message.messageId} isNotDecrypted: ${message.decryptedHTML.isNullOrEmpty()}") if (!message.decryptedHTML.isNullOrEmpty()) { emit(MessageBodyState.Success(message)) } else { val userId = userManager.requireCurrentUserId() val messageId = requireNotNull(message.messageId) val fetchedMessage = messageRepository.getMessage(userId, messageId, true) ?: return@flow val verificationKeys = runCatching { fetchVerificationKeys.invoke(message.senderEmail) }.getOrNull() val isDecrypted = fetchedMessage.tryDecrypt(verificationKeys) Timber.v("message $messageId isDecrypted, isRead: ${fetchedMessage.isRead}") if (!fetchedMessage.isRead && visibleToTheUser) { messageRepository.markRead(listOf(messageId)) } if (isDecrypted == true) { emit(MessageBodyState.Success(fetchedMessage)) } else { emit(MessageBodyState.Error.DecryptionError(fetchedMessage)) } } }.flowOn(dispatchers.Io) @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE) fun pause() { visibleToTheUser = false } @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) fun resume() { visibleToTheUser = true } private suspend fun loadMessageDetails(message: Message?): ConversationUiModel? { val userId = userManager.currentUserId ?: return null val messageWithDetails = if (message == null || !message.isDownloaded) { Timber.v("Message is not downloaded, trying to fetch it") userManager.currentUserId ?.let { messageRepository.getMessage(it, messageOrConversationId, true) } } else { message } if (messageWithDetails == null || !messageWithDetails.isDownloaded) { Timber.i("Failed fetching Message Details for message $messageOrConversationId") _messageDetailsError.postValue(Event("Failed getting message details")) return null } val contact = contactsRepository.findContactEmailByEmail(userId, messageWithDetails.senderEmail) val contactName = contact?.name?.takeIfNotBlank() if (contactName != null && contactName != contact.email) { messageWithDetails.senderDisplayName = contact.name } return messageWithDetails.toConversationUiModel() } fun isConversationEnabled() = conversationModeEnabled(location) fun doesConversationHaveMoreThanOneMessage() = runBlocking { val messagesCount = conversationUiModel.first().messagesCount if (messagesCount != null) messagesCount > 1 else false } private suspend fun loadConversationDetails( result: DataResult, userId: UserId ): ConversationUiModel? { return when (result) { is DataResult.Success -> { Timber.v("loadConversationDetails Success") val conversation = result.value if (conversation.messages?.isEmpty() == true) { Timber.i("Failed getting conversation details, empty messages") null } else { onConversationLoaded(conversation, userId) } } is DataResult.Error -> { Timber.d("loadConversationDetails $messageOrConversationId Error - cause: ${result.cause}") _messageDetailsError.postValue(Event("Failed getting conversation details")) null } else -> { Timber.v("loadConversationDetails result ${result.javaClass.canonicalName}") null } } } private suspend fun onConversationLoaded( conversation: Conversation, userId: UserId ): ConversationUiModel? { val messages = conversation.messages?.mapNotNull { message -> messageRepository.findMessage(userId, message.id)?.let { localMessage -> val contact = contactsRepository.findContactEmailByEmail(userId, localMessage.senderEmail) val contactName = contact?.name?.takeIfNotBlank() if (contactName != null && contactName != contact.email) { localMessage.senderDisplayName = contact.name } localMessage } } if (messages.isNullOrEmpty()) { Timber.d("Failed fetching Message Details for message $messageOrConversationId") _messageDetailsError.postValue(Event("Failed getting conversation's messages")) return null } return conversation.toConversationUiModel().copy( messages = messages.sortedBy { it.time } ) } private suspend fun emitConversationUiItem(conversationUiModel: ConversationUiModel) { _decryptedConversationUiModel.postValue(conversationUiModel) _conversationUiFlow.emit(conversationUiModel) } private fun Message.tryDecrypt(verificationKeys: List?): Boolean? { return try { decrypt(userManager, userManager.requireCurrentUserId(), verificationKeys) Timber.d("decrypted verificationKeys size: ${verificationKeys?.size}, body size: ${messageBody?.length}") true } catch (exception: Exception) { // signature verification failed with special case, try to decrypt again without verification // and hardcode verification error if (verificationKeys != null && verificationKeys.isNotEmpty() && exception.isSignatureError() ) { Timber.i(exception, "Decrypting message again without verkeys") decrypt(userManager, userManager.requireCurrentUserId()) this.hasValidSignature = false this.hasInvalidSignature = !exception.isMessageNotSignedError() true } else { Timber.w(exception, "Cannot decrypt message") false } } } private fun Exception.isSignatureError() = message?.matches("Signature Verification Error: .+".toRegex()) == true private fun Exception.isMessageNotSignedError() = message?.equals("Signature Verification Error: Missing signature") == true fun startDownloadEmbeddedImagesJob(message: Message, embeddedImageIds: List) { hasEmbeddedImages = false viewModelScope.launch(dispatchers.Io) { val messageId = message.messageId ?: return@launch val attachmentMetadataList = attachmentMetadataDao.getAllAttachmentsForMessage(messageId) val embeddedImages = embeddedImagesAttachments.mapNotNull { embeddedImage -> attachmentsHelper.fromAttachmentToEmbeddedImage(embeddedImage, embeddedImageIds) } val embeddedImagesWithLocalFiles = mutableListOf() embeddedImages.forEach { embeddedImage -> attachmentMetadataList.find { it.id == embeddedImage.attachmentId }?.let { embeddedImagesWithLocalFiles.add( embeddedImage.copy(localFileName = it.localLocation.substringAfterLast("/")) ) } } // don't download embedded images, if we already have them in local storage if ( embeddedImagesWithLocalFiles.isNotEmpty() && embeddedImagesWithLocalFiles.all { it.localFileName != null } ) { AppUtil.postEventOnUi(DownloadEmbeddedImagesEvent(Status.SUCCESS, embeddedImagesWithLocalFiles)) } else { messageDetailsRepository.startDownloadEmbeddedImages( messageId, userManager.requireCurrentUserId() ) } } } fun onEmbeddedImagesDownloaded(event: DownloadEmbeddedImagesEvent) { viewModelScope.launch { Timber.v("onEmbeddedImagesDownloaded status: ${event.status} images size: ${event.images.size}") val messageId = event.images.first().messageId val renderedMessage = try { messageRenderer.setImagesAndProcess(messageId, event.images) } catch (e: IllegalStateException) { if (e is CancellationException) throw e Timber.e(e) return@launch } val updatedMessage = updateUiModelMessageWithFormattedHtml( renderedMessage.messageId, renderedMessage.renderedHtmlBody ) ?: run { Timber.e("Cannot update message with formatted html. Message id: $messageId") return@launch } Timber.v("Update rendered HTML message id: ${updatedMessage.messageId}") _messageRenderedWithImages.value = updatedMessage areImagesDisplayed = true } } fun prepareEditMessageIntent( messageAction: Constants.MessageActionType, message: Message, newMessageTitle: String?, content: String, mBigContentHolder: BigContentHolder ) { val user: User = userManager.requireCurrentLegacyUser() viewModelScope.launch { val intent = messageDetailsRepository.prepareEditMessageIntent( messageAction, message, user, newMessageTitle, content, mBigContentHolder, areImagesDisplayed, remoteContentDisplayed, embeddedImagesAttachments, dispatchers.Io ) _prepareEditMessageIntentResult.value = Event(intent) } } fun viewOrDownloadAttachment(context: Context, attachment: Attachment) { viewModelScope.launch(dispatchers.Io) { val attachmentId = requireNotNull(attachment.attachmentId) val messageId = attachment.messageId val metadata = attachmentMetadataDao.getAttachmentMetadataForMessageAndAttachmentId(messageId, attachmentId) Timber.v("viewOrDownloadAttachment Id: $attachmentId metadataId: ${metadata?.id}") val uri = metadata?.uri // extra check if user has not deleted the file if (uri != null && attachmentsHelper.isFileAvailable(uri)) { val path = checkNotNull(uri.path) if (DIR_EMB_ATTACHMENT_DOWNLOADS in path) { copyAttachmentToDownloadsAndDisplay(context, attachmentId, metadata.name, uri) } else { viewAttachment(attachmentId, metadata.name, uri) } } else { Timber.d("Attachment id: $attachmentId file not available, uri: $uri ") attachmentsWorker.enqueue(messageId, userManager.requireCurrentUserId(), attachmentId) } } } /** * Explicitly make a copy of embedded attachment to downloads and display it (product requirement) */ private fun copyAttachmentToDownloadsAndDisplay( context: Context, attachmentId: String, filename: String, uri: Uri ) { val newUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { getCopiedUriFromQ(filename, uri, context) } else { getCopiedUriBeforeQ(filename, uri, context) } Timber.v("Copied attachment file from ${uri.path} to ${newUri?.path}") viewAttachment(attachmentId, filename, newUri) } @TargetApi(Build.VERSION_CODES.Q) private fun getCopiedUriFromQ(filename: String, uri: Uri, context: Context): Uri? { val contentResolver = context.contentResolver return contentResolver.openInputStream(uri)?.let { attachmentsHelper.saveAttachmentInMediaStore( contentResolver, filename, contentResolver.getType(uri), it ) } } private fun getCopiedUriBeforeQ(filename: String, uri: Uri, context: Context): Uri { val fileInDownloads = File( Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), filename ) context.contentResolver.openInputStream(uri)?.use { stream -> fileInDownloads.sink().buffer().use { sink -> sink.writeAll(stream.source()) } } return FileProvider.getUriForFile( context, context.applicationContext.packageName + ".provider", fileInDownloads ) } fun viewAttachment(attachmentId: String, filename: String?, uri: Uri?) { uri ?: return val shouldBeOpenedInProtonCalendar = protonCalendarUtil.isProtonCalendarInstalled() && isCalendarAttachment(attachmentId) if (shouldBeOpenedInProtonCalendar) { viewAttachmentInProtonCalendar(attachmentId, uri, filename) } else { downloadUtils.viewAttachment(filename, uri) } } private fun isCalendarAttachment(attachmentId: String): Boolean = (attachmentId == calendarAttachmentId).also { calendarAttachmentId = null } private fun viewAttachmentInProtonCalendar(attachmentId: String, uri: Uri, filename: String?) { viewModelScope.launch(dispatchers.Io) { val conversation = conversationUiModel.first() val message = conversation.messages .find { message -> attachmentId in message.attachments.map { it.attachmentId } } ?: return@launch val senderEmail = EmailAddress(message.senderEmail) val header = message.header val recipientEmail = if (header != null) { protonCalendarUtil.extractRecipientEmailOrNull(header) ?: EmailAddress(message.toList.first().emailAddress) } else { val messageToEmails = message.toList.map { EmailAddress(it.emailAddress) } val currentUserAddresses = userManager.requireCurrentUser().addresses.addresses.values currentUserAddresses.first { it.email in messageToEmails }.email } val mimeType = downloadUtils.getMimeType(uri, filename) Timber.d("viewAttachment mimeType: $mimeType uri: $uri uriScheme: ${uri.scheme}") try { protonCalendarUtil.openIcsInProtonCalendar(uri, senderEmail, recipientEmail) } catch (notFoundException: ActivityNotFoundException) { Timber.i(notFoundException, "Unable to view attachment with ProtonCalendar") } } } fun openInProtonCalendar(context: Context, message: Message) { if (protonCalendarUtil.isProtonCalendarInstalled()) { val attachment = protonCalendarUtil.requireCalendarAttachment(message) calendarAttachmentId = requireNotNull(attachment.attachmentId) viewOrDownloadAttachment(context, attachment) } else { try { protonCalendarUtil.openProtonCalendarOnPlayStore() } catch (e: ActivityNotFoundException) { Timber.e(e) _messageDetailsError.value = Event(context.getString(R.string.details_play_store_error)) } } } fun remoteContentDisplayed() { remoteContentDisplayed = true } fun displayRemoteContent(message: Message) { remoteContentDisplayed() prepareEmbeddedImages(message) } fun isEmbeddedImagesDisplayed() = areImagesDisplayed fun displayEmbeddedImages(message: Message) { areImagesDisplayed = true // this will be passed to edit intent startDownloadEmbeddedImagesJob(message, message.embeddedImageIds) } fun isAutoShowEmbeddedImages(): Boolean { val mailSettings = userManager.getCurrentUserMailSettingsBlocking() return mailSettings?.showImagesFrom?.includesEmbedded() ?: false } fun prepareEmbeddedImages(message: Message): Boolean { val attachments = message.attachments val embeddedImagesToFetch = ArrayList() val embeddedImagesAttachments = ArrayList() for (attachment in attachments) { val embeddedImage = attachmentsHelper .fromAttachmentToEmbeddedImage(attachment, message.embeddedImageIds) ?: continue embeddedImagesToFetch.add(embeddedImage) embeddedImagesAttachments.add(attachment) } this.embeddedImagesToFetch = embeddedImagesToFetch this.embeddedImagesAttachments = embeddedImagesAttachments if (embeddedImagesToFetch.isNotEmpty()) { hasEmbeddedImages = true } return hasEmbeddedImages } fun printMessage(messageId: String, activityContext: Context) { _decryptedConversationUiModel.value?.messages?.find { it.messageId == messageId }?.let { MessagePrinter( activityContext, activityContext.resources, activityContext.getSystemService(Context.PRINT_SERVICE) as PrintManager, remoteContentDisplayed ).printMessage(it, it.decryptedHTML ?: "") } } fun formatMessageHtmlBody( message: Message, windowWidth: Int, css: String, darkModeCss: String, defaultErrorMessage: String ): String { val messageId = requireNotNull(message.messageId) { "message id is null" } val formattedHtml = try { val contentTransformer = DefaultTransformer() .pipe(ViewportTransformer(windowWidth, css, darkModeCss)) contentTransformer.transform(Jsoup.parse(message.decryptedHTML)).toString() } catch (ioException: IOException) { Timber.e(ioException, "Jsoup is unable to parse HTML message details") defaultErrorMessage } updateUiModelMessageWithFormattedHtml(message.messageId, formattedHtml, message.decryptedBody) // Set the body of the message currently being displayed in messageRenderer to allow embedded images loading messageRenderer.setMessageBody(messageId, formattedHtml) return formattedHtml } private fun updateUiModelMessageWithFormattedHtml( messageId: String?, formattedHtml: String?, decryptedBody: String? = null ): Message? { // Needed to ensure the `decryptedHTML` is available on a message when an action is executed on it, // since most of the click listeners in `MessageDetailsActivity` that trigger actions (such as // reply or printMessage) hold a reference to the message in the `conversationUiModel object. // This is considered tech debt and detailed in MAILAND-2119 val currentUiModel = _decryptedConversationUiModel.value val message = currentUiModel?.messages?.find { it.messageId == messageId } decryptedBody?.let { message?.decryptedBody = it } message?.decryptedHTML = formattedHtml return message } fun moveToTrash() { viewModelScope.launch { val primaryUserId = userManager.requireCurrentUserId() if (isConversationEnabled()) { moveConversationsToFolder( listOf(messageOrConversationId), primaryUserId, Constants.MessageLocationType.TRASH.messageLocationTypeValue.toString() ) } else { moveMessagesToFolder( listOf(messageOrConversationId), Constants.MessageLocationType.TRASH.messageLocationTypeValue.toString(), location.messageLocationTypeValue.toString(), primaryUserId ) } } } fun moveDraftToTrash(messageId: String) { viewModelScope.launch { moveMessagesToFolder( listOf(messageId), Constants.MessageLocationType.TRASH.asLabelIdString(), Constants.MessageLocationType.DRAFT.asLabelIdString(), userManager.requireCurrentUserId() ) } } fun delete() { viewModelScope.launch { val primaryUserId = userManager.requireCurrentUserId() if (isConversationEnabled()) { deleteConversations( listOf(messageOrConversationId), primaryUserId, location.messageLocationTypeValue.toString() ) } else { deleteMessage( listOf(messageOrConversationId), location.messageLocationTypeValue.toString(), primaryUserId ) } } } fun handleStarUnStar(messageOrConversationId: String, isChecked: Boolean) { val ids = listOf(messageOrConversationId) val primaryUserId = userManager.requireCurrentUserId() viewModelScope.launch { if (isConversationEnabled()) { val starAction = if (isChecked) { ChangeConversationsStarredStatus.Action.ACTION_STAR } else { ChangeConversationsStarredStatus.Action.ACTION_UNSTAR } changeConversationsStarredStatus( ids, primaryUserId, starAction ) } else { if (isChecked) { changeMessagesStarredStatus( primaryUserId, ids, ChangeMessagesStarredStatus.Action.ACTION_STAR ) } else { changeMessagesStarredStatus( primaryUserId, ids, ChangeMessagesStarredStatus.Action.ACTION_UNSTAR ) } } } } fun sendPhishingReport(message: Message, jobManager: JobManager) { jobManager.addJobInBackground( ReportPhishingJob( requireNotNull(message.messageId), requireNotNull(message.decryptedBody), requireNotNull(message.mimeType) ) ) } fun storagePermissionDenied() { _showPermissionMissingDialog.value = Unit } fun shouldShowDeleteActionInBottomActionBar(): Boolean { return if (isConversationEnabled() && doesConversationHaveMoreThanOneMessage()) { location == Constants.MessageLocationType.TRASH } else { location in arrayOf( Constants.MessageLocationType.DRAFT, Constants.MessageLocationType.ALL_DRAFT, Constants.MessageLocationType.SENT, Constants.MessageLocationType.ALL_SENT, Constants.MessageLocationType.TRASH, Constants.MessageLocationType.SPAM ) } } fun isAppInDarkMode(context: Context) = isAppInDarkMode.invoke(context) fun isWebViewInDarkModeBlocking(context: Context, messageId: String) = runBlocking { getViewInDarkModeMessagePreference(context, userManager.requireCurrentUserId(), messageId) } fun reloadMessage(messageId: String) { _reloadMessageFlow.tryEmit(messageId) } }