proton-mail-android/app/src/main/java/ch/protonmail/android/details/presentation/ui/MessageDetailsActivity.kt

968 lines
40 KiB
Kotlin

/*
* 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.details.presentation.ui
import android.app.AlertDialog
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.ContextMenu
import android.view.ContextMenu.ContextMenuInfo
import android.view.MenuItem
import android.view.View
import android.view.animation.AlphaAnimation
import android.webkit.WebView
import android.webkit.WebView.HitTestResult
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.viewModels
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.content.getSystemService
import androidx.core.view.ViewCompat
import androidx.core.view.isVisible
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import ch.protonmail.android.R
import ch.protonmail.android.activities.BaseStoragePermissionActivity
import ch.protonmail.android.activities.StartCompose
import ch.protonmail.android.activities.composeMessage.ComposeMessageActivity
import ch.protonmail.android.activities.messageDetails.IntentExtrasData
import ch.protonmail.android.activities.messageDetails.MessageDetailsAdapter
import ch.protonmail.android.activities.messageDetails.viewmodel.MessageDetailsViewModel
import ch.protonmail.android.core.Constants
import ch.protonmail.android.data.local.model.Attachment
import ch.protonmail.android.data.local.model.Message
import ch.protonmail.android.details.presentation.mapper.MessageEncryptionUiModelMapper
import ch.protonmail.android.details.presentation.mapper.MessageToMessageDetailsListItemMapper
import ch.protonmail.android.details.presentation.model.ConversationUiModel
import ch.protonmail.android.details.presentation.model.MessageBodyState
import ch.protonmail.android.events.DownloadEmbeddedImagesEvent
import ch.protonmail.android.events.DownloadedAttachmentEvent
import ch.protonmail.android.events.PostPhishingReportEvent
import ch.protonmail.android.events.Status
import ch.protonmail.android.feature.rating.MailboxScreenViewInMemoryRepository
import ch.protonmail.android.jobs.PostSpamJob
import ch.protonmail.android.labels.domain.model.LabelId
import ch.protonmail.android.labels.domain.model.LabelType
import ch.protonmail.android.labels.presentation.ui.LabelsActionSheet
import ch.protonmail.android.settings.data.AccountSettingsRepository
import ch.protonmail.android.ui.actionsheet.MessageActionSheet
import ch.protonmail.android.ui.actionsheet.model.ActionSheetTarget
import ch.protonmail.android.util.ProtonCalendarUtil
import ch.protonmail.android.utils.AppUtil
import ch.protonmail.android.utils.Event
import ch.protonmail.android.utils.MessageUtils
import ch.protonmail.android.utils.UiUtil
import ch.protonmail.android.utils.extensions.showToast
import ch.protonmail.android.utils.ui.dialogs.DialogUtils
import ch.protonmail.android.utils.ui.dialogs.DialogUtils.Companion.showDeleteConfirmationDialog
import ch.protonmail.android.utils.ui.dialogs.DialogUtils.Companion.showTwoButtonInfoDialog
import ch.protonmail.android.utils.ui.screen.RenderDimensionsProvider
import ch.protonmail.android.utils.webview.SetUpWebViewDarkModeHandlingIfSupported
import ch.protonmail.android.views.messageDetails.BottomActionsView
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.snackbar.Snackbar
import com.squareup.otto.Subscribe
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.android.synthetic.main.activity_message_details.*
import kotlinx.android.synthetic.main.layout_message_details_activity_toolbar.*
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import me.proton.core.domain.entity.UserId
import me.proton.core.util.kotlin.EMPTY_STRING
import timber.log.Timber
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
import kotlin.math.abs
private const val TITLE_ANIMATION_THRESHOLD = 0.9
private const val TITLE_ANIMATION_DURATION = 200L
private const val ONE_HUNDRED_PERCENT = 1.0
@AndroidEntryPoint
internal class MessageDetailsActivity : BaseStoragePermissionActivity() {
@Inject
lateinit var messageToMessageDetailsListItemMapper: MessageToMessageDetailsListItemMapper
@Inject
lateinit var messageEncryptionUiModelMapper: MessageEncryptionUiModelMapper
@Inject
lateinit var renderDimensionsProvider: RenderDimensionsProvider
@Inject
lateinit var setUpWebViewDarkModeHandlingIfSupported: SetUpWebViewDarkModeHandlingIfSupported
@Inject
lateinit var protonCalendarUtil: ProtonCalendarUtil
@Inject
lateinit var accountSettingsRepository: AccountSettingsRepository
@Inject
lateinit var mailboxScreenViewRepository: MailboxScreenViewInMemoryRepository
private lateinit var messageOrConversationId: String
private lateinit var messageExpandableAdapter: MessageDetailsAdapter
private lateinit var primaryBaseActivity: Context
private var messageRecipientUserId: UserId? = null
private var messageRecipientUsername: String? = null
private var openedFolderLocationId: Int = Constants.MessageLocationType.INVALID.messageLocationTypeValue
private var openedFolderLabelId: String? = null
private var onOffsetChangedListener: AppBarLayout.OnOffsetChangedListener? = null
private var showPhishingReportButton = true
private var shouldScrollToPosition = true
private val attachmentToDownload = AtomicReference<Attachment?>(null)
private val viewModel: MessageDetailsViewModel by viewModels()
/** Lazy instance of [ClipboardManager] that will be used for copy content into the Clipboard */
private val clipboardManager by lazy { getSystemService<ClipboardManager>() }
private val recyclerViewLinearLayoutManager = LinearLayoutManager(this)
private val startComposeLauncher = registerForActivityResult(StartCompose()) { messageId ->
messageId?.let {
val snack = Snackbar.make(
findViewById(R.id.messageDetailsView),
R.string.snackbar_message_draft_saved,
Snackbar.LENGTH_LONG
)
snack.setAction(R.string.move_to_trash) {
viewModel.moveDraftToTrash(messageId)
Snackbar.make(
findViewById(R.id.messageDetailsView),
R.string.snackbar_message_draft_moved_to_trash,
Snackbar.LENGTH_LONG
).show()
}
snack.show()
}
}
override fun getLayoutId(): Int = R.layout.activity_message_details
override fun storagePermissionGranted() {
val attachmentToDownload = attachmentToDownload.getAndSet(null)
if (attachmentToDownload?.attachmentId?.isNotEmpty() == true) {
viewModel.viewOrDownloadAttachment(this, attachmentToDownload)
}
}
override fun checkForPermissionOnStartup(): Boolean = false
override fun attachBaseContext(base: Context) {
primaryBaseActivity = base
super.attachBaseContext(base)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
messageOrConversationId = requireNotNull(intent.getStringExtra(EXTRA_MESSAGE_OR_CONVERSATION_ID))
messageRecipientUserId = intent.getStringExtra(EXTRA_MESSAGE_RECIPIENT_USER_ID)?.let(::UserId)
messageRecipientUsername = intent.getStringExtra(EXTRA_MESSAGE_RECIPIENT_USERNAME)
openedFolderLocationId = intent.getIntExtra(
EXTRA_MESSAGE_LOCATION_ID,
Constants.MessageLocationType.INVALID.messageLocationTypeValue
)
openedFolderLabelId = intent.getStringExtra(EXTRA_MAILBOX_LABEL_ID)
expandedToolbarTitleTextView.text = intent.getStringExtra(EXTRA_MESSAGE_SUBJECT) ?: ""
val currentUser = mUserManager.requireCurrentUser()
supportActionBar?.title = null
initAdapters()
initRecyclerView()
val recipientUsername = messageRecipientUsername
if (recipientUsername != null && currentUser.name.s != recipientUsername) {
val userId = checkNotNull(messageRecipientUserId) { "Username found in extras, but user id" }
accountStateManager.switch(userId).invokeOnCompletion {
continueSetup()
invalidateOptionsMenu()
}
} else {
continueSetup()
}
// Copy Subject to Clipboard at long press
expandedToolbarTitleTextView.setOnLongClickListener {
clipboardManager?.let {
it.setPrimaryClip(
ClipData.newPlainText(getString(R.string.email_subject), expandedToolbarTitleTextView.text)
)
showToast(R.string.subject_copied, Toast.LENGTH_SHORT)
true
} ?: false
}
starToggleButton.setOnCheckedChangeListener { buttonView, isChecked ->
if (!buttonView.isPressed) {
return@setOnCheckedChangeListener
}
viewModel.handleStarUnStar(messageOrConversationId, isChecked)
}
viewModel.reloadMessageFlow
.onEach { messageId ->
messageExpandableAdapter.reloadMessage(messageId)
}
.launchIn(lifecycleScope)
lifecycle.addObserver(viewModel)
}
private fun continueSetup() {
viewModel.decryptedConversationUiModel.observe(this, ConversationUiModelObserver())
viewModel.messageRenderedWithImages.observe(this) { message ->
val messageId = message.messageId ?: return@observe
messageExpandableAdapter.showMessageDetails(
message.decryptedHTML,
messageId,
false,
false,
message.attachments,
message.embeddedImageIds,
hasValidSignature = message.hasValidSignature,
hasInvalidSignature = message.hasInvalidSignature,
)
}
viewModel.messageDetailsError.observe(this, MessageDetailsErrorObserver())
listenForConnectivityEvent()
observeEditMessageEvents()
observePermissionMissingDialogTrigger()
}
private fun initAdapters() {
messageExpandableAdapter = MessageDetailsAdapter(
context = this,
messages = emptyList(),
messageDetailsRecyclerView = messageDetailsRecyclerView,
messageToMessageDetailsListItemMapper = messageToMessageDetailsListItemMapper,
userManager = mUserManager,
accountSettingsRepository = accountSettingsRepository,
messageEncryptionUiModelMapper = messageEncryptionUiModelMapper,
setUpWebViewDarkModeHandlingIfSupported = setUpWebViewDarkModeHandlingIfSupported,
onLoadEmbeddedImagesClicked = ::onLoadEmbeddedImagesClicked,
onDisplayRemoteContentClicked = ::onDisplayRemoteContentClicked,
protonCalendarUtil = protonCalendarUtil,
onLoadMessageBody = ::onLoadMessageBody,
onAttachmentDownloadCallback = ::onDownloadAttachment,
onOpenInProtonCalendarClicked = { viewModel.openInProtonCalendar(this, it) },
onEditDraftClicked = ::onEditDraftClicked,
onReplyMessageClicked = ::onReplyMessageClicked,
onMoreMessageActionsClicked = ::onShowMessageActionSheet
)
}
private fun initRecyclerView() {
messageDetailsRecyclerView.layoutManager = recyclerViewLinearLayoutManager
messageDetailsRecyclerView.adapter = messageExpandableAdapter
}
private fun onLoadMessageBody(message: Message) {
if (message.messageId != null) {
viewModel.loadMessageBody(message).mapLatest { messageBodyState ->
val showDecryptionError = messageBodyState is MessageBodyState.Error.DecryptionError
val loadedMessage = messageBodyState.message
val messageId = loadedMessage.messageId ?: return@mapLatest
val parsedBody = viewModel.formatMessageHtmlBody(
loadedMessage,
renderDimensionsProvider.getRenderWidth(this),
AppUtil.readTxt(this, R.raw.css_reset_with_custom_props),
if (viewModel.isAppInDarkMode(this) &&
viewModel.isWebViewInDarkModeBlocking(this, messageId)
) {
AppUtil.readTxt(this, R.raw.css_reset_dark_mode_only)
} else {
EMPTY_STRING
},
this.getString(R.string.request_timeout)
)
val showLoadEmbeddedImagesButton = handleEmbeddedImagesLoading(loadedMessage)
messageExpandableAdapter.showMessageDetails(
parsedBody,
messageId,
showLoadEmbeddedImagesButton,
showDecryptionError,
loadedMessage.attachments,
loadedMessage.embeddedImageIds,
hasValidSignature = loadedMessage.hasValidSignature,
hasInvalidSignature = loadedMessage.hasInvalidSignature,
)
}.launchIn(lifecycleScope)
}
}
private fun handleEmbeddedImagesLoading(message: Message): Boolean {
val hasEmbeddedImages = viewModel.prepareEmbeddedImages(message)
if (!hasEmbeddedImages) {
// Let client know the 'load images' button should not be shown
return false
}
val displayEmbeddedImages = viewModel.isAutoShowEmbeddedImages() || viewModel.isEmbeddedImagesDisplayed()
if (displayEmbeddedImages) {
viewModel.displayEmbeddedImages(message)
}
// Let client know whether the 'load images' button should be shown
return !displayEmbeddedImages
}
override fun onCreateContextMenu(menu: ContextMenu?, v: View?, menuInfo: ContextMenuInfo?) {
super.onCreateContextMenu(menu, v, menuInfo)
val webView = v as WebView
val result = webView.hitTestResult
val type = result.type
if (listOf(HitTestResult.UNKNOWN_TYPE, HitTestResult.EDIT_TEXT_TYPE).contains(type)) {
return
}
if (listOf(HitTestResult.EMAIL_TYPE, HitTestResult.SRC_ANCHOR_TYPE).contains(type)) {
menu?.add(getString(R.string.copy_link))?.setOnMenuItemClickListener(Copy(result.extra))
menu?.add(getString(R.string.share_link))?.setOnMenuItemClickListener(Share(result.extra))
}
}
override fun onStart() {
super.onStart()
checkDelinquency()
mApp.bus.register(this)
viewModel.checkConnectivity()
mApp.bus.register(viewModel)
}
override fun onStop() {
super.onStop()
mApp.bus.unregister(viewModel)
mApp.bus.unregister(this)
}
override fun onBackPressed() {
mailboxScreenViewRepository.recordScreenView()
super.onBackPressed()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
}
return true
}
override fun onPermissionDenied(type: Constants.PermissionType) {
super.onPermissionDenied(type)
viewModel.storagePermissionDenied()
}
fun showReportPhishingDialog(messageId: String) {
val message = viewModel.decryptedConversationUiModel.value?.messages?.find { it.messageId == messageId }
AlertDialog.Builder(this)
.setTitle(R.string.phishing_dialog_title)
.setMessage(R.string.phishing_dialog_message)
.setPositiveButton(R.string.send) { _: DialogInterface?, _: Int ->
showPhishingReportButton = false
if (message != null) {
viewModel.sendPhishingReport(message, mJobManager)
} else {
showToast(R.string.cannot_send_report_send, Toast.LENGTH_SHORT)
}
}
.setNegativeButton(R.string.cancel, null).show()
}
private fun showNoConnSnackExtended(connectivity: Constants.ConnectionState) {
Timber.v("Show no connection")
networkSnackBarUtil.hideAllSnackBars()
networkSnackBarUtil.getNoConnectionSnackBar(
mSnackLayout,
mUserManager.requireCurrentLegacyUser(),
this,
{ onConnectivityCheckRetry() },
anchorViewId = R.id.messageDetailsActionsView,
isOffline = connectivity == Constants.ConnectionState.NO_INTERNET
).show()
val constraintSet = ConstraintSet()
constraintSet.clone(messageDetailsView)
constraintSet.connect(
R.id.coordinatorLayout, ConstraintSet.BOTTOM, R.id.layout_no_connectivity_info, ConstraintSet.TOP, 0
)
constraintSet.applyTo(messageDetailsView)
}
private fun onConnectivityCheckRetry() {
networkSnackBarUtil.getCheckingConnectionSnackBar(
mSnackLayout,
R.id.messageDetailsActionsView
).show()
viewModel.checkConnectivityDelayed()
}
private fun hideNoConnSnackExtended() {
networkSnackBarUtil.hideNoConnectionSnackBar()
val constraintSet = ConstraintSet()
constraintSet.clone(messageDetailsView)
constraintSet.connect(
R.id.coordinatorLayout, ConstraintSet.BOTTOM, R.id.messageDetailsActionsView, ConstraintSet.TOP, 0
)
constraintSet.applyTo(messageDetailsView)
}
private fun listenForConnectivityEvent() {
viewModel.hasConnectivity.observe(
this,
{ isConnectionActive ->
Timber.v("isConnectionActive:${isConnectionActive.name}")
if (isConnectionActive != Constants.ConnectionState.CONNECTED) {
showNoConnSnackExtended(isConnectionActive)
} else {
hideNoConnSnackExtended()
}
}
)
}
@Subscribe
@Suppress("unused")
fun onPostPhishingReportEvent(event: PostPhishingReportEvent) {
val status = event.status
val toastMessageId: Int
when (status) {
Status.SUCCESS -> {
mJobManager.addJobInBackground(PostSpamJob(listOf(messageOrConversationId)))
toastMessageId = R.string.phishing_report_send_message_moved_to_spam
finish()
}
Status.STARTED,
Status.FAILED,
Status.NO_NETWORK,
Status.UNAUTHORIZED -> {
showPhishingReportButton = true
toastMessageId = R.string.cannot_send_report_send
}
else -> throw IllegalStateException("Unknown message status: $status")
}
showToast(toastMessageId, Toast.LENGTH_SHORT)
}
private fun getDecryptedBody(decryptedHtml: String?): String {
var decryptedBody = decryptedHtml
if (decryptedBody.isNullOrEmpty()) {
decryptedBody = getString(R.string.empty_message)
}
val regex2 = "<body[^>]*>"
// break the backgrounds and other urls
decryptedBody = decryptedBody.replace(regex2.toRegex(), "<body>")
return decryptedBody
}
@Subscribe
@Suppress("unused")
fun onDownloadEmbeddedImagesEvent(event: DownloadEmbeddedImagesEvent) {
when (event.status) {
Status.SUCCESS -> {
viewModel.onEmbeddedImagesDownloaded(event)
}
Status.NO_NETWORK -> {
showToast(R.string.load_embedded_images_failed_no_network)
}
Status.FAILED -> {
showToast(R.string.load_embedded_images_failed)
}
Status.STARTED -> {
viewModel.hasEmbeddedImages = false
}
Status.UNAUTHORIZED -> {
// NOOP, when on enums should be exhaustive
}
else -> {
// NOOP, when on enums should be exhaustive
}
}
}
@Subscribe
@Suppress("unused")
fun onDownloadAttachmentEvent(event: DownloadedAttachmentEvent) {
when (val status = event.status) {
Status.STARTED, Status.SUCCESS -> {
val isDownloaded = Status.SUCCESS == status
if (isDownloaded) {
viewModel.viewAttachment(event.attachmentId, event.filename, event.attachmentUri)
} else {
showToast(R.string.downloading)
}
}
Status.FAILED, Status.VALIDATION_FAILED -> showToast(R.string.cant_download_attachment)
Status.NO_NETWORK,
Status.UNAUTHORIZED -> {
// NOOP, when on enums should be exhaustive
}
}
}
private inner class Copy(private val text: CharSequence?) : MenuItem.OnMenuItemClickListener {
override fun onMenuItemClick(item: MenuItem): Boolean {
UiUtil.copy(this@MessageDetailsActivity, text)
return true
}
}
private inner class Share(private val uri: String?) : MenuItem.OnMenuItemClickListener {
override fun onMenuItemClick(item: MenuItem): Boolean {
val send = Intent(Intent.ACTION_SEND)
send.type = "text/plain"
send.putExtra(Intent.EXTRA_TEXT, uri)
try {
startActivity(Intent.createChooser(send, getText(R.string.share_link)))
} catch (ex: ActivityNotFoundException) {
// if no app handles it, do nothing
Timber.e(ex)
}
return true
}
}
private inner class ConversationUiModelObserver : Observer<ConversationUiModel> {
override fun onChanged(conversation: ConversationUiModel) {
val lastNonDraftMessage = conversation.messages.lastOrNull { it.isDraft().not() }
if (lastNonDraftMessage != null) {
setupLastMessageActionsListener(lastNonDraftMessage)
} else {
messageDetailsActionsView.isVisible = false
}
setupToolbarOffsetListener(conversation.messages.count())
displayToolbarData(conversation)
Timber.v("setMessage conversations size: ${conversation.messages.size}")
messageExpandableAdapter.setMessageData(conversation)
if (isAutoShowRemoteImages) {
viewModel.remoteContentDisplayed()
}
progress.visibility = View.GONE
invalidateOptionsMenu()
// Scroll to the last message if there is more than one message,
// i.e. the item count is greater than 2 (header and body)
if (shouldScrollToPosition && messageExpandableAdapter.itemCount > 2) {
scrollToTheExpandedMessage(lastNonDraftMessage)
shouldScrollToPosition = false
}
viewModel.renderingPassed = true
}
}
private fun setupToolbarOffsetListener(messagesCount: Int) {
// Ensure we do only set onOffsetChangedListener once
if (onOffsetChangedListener != null) {
return
}
var areCollapsedViewsShown = false
onOffsetChangedListener = AppBarLayout.OnOffsetChangedListener { appBarLayout, verticalOffset ->
val scrolledPercentage = abs(verticalOffset).toFloat() / appBarLayout.totalScrollRange.toFloat()
val collapsedViewsAnimation = if (areCollapsedViewsShown) AlphaAnimation(1f, 0f) else AlphaAnimation(0f, 1f)
collapsedViewsAnimation.duration = TITLE_ANIMATION_DURATION
collapsedViewsAnimation.fillAfter = true
// Animate collapsed toolbar views
val shouldDisplayCollapsedViews = scrolledPercentage >= TITLE_ANIMATION_THRESHOLD && !areCollapsedViewsShown
val shouldHideCollapsedViews = scrolledPercentage < TITLE_ANIMATION_THRESHOLD && areCollapsedViewsShown
if (shouldDisplayCollapsedViews) {
collapsedToolbarTitleTextView.startAnimation(collapsedViewsAnimation)
collapsedToolbarMessagesCountTextView.startAnimation(collapsedViewsAnimation)
collapsedToolbarTitleTextView.visibility = View.VISIBLE
collapsedToolbarMessagesCountTextView.isVisible = messagesCount > 1
areCollapsedViewsShown = true
} else if (shouldHideCollapsedViews) {
collapsedToolbarTitleTextView.startAnimation(collapsedViewsAnimation)
collapsedToolbarMessagesCountTextView.startAnimation(collapsedViewsAnimation)
collapsedToolbarTitleTextView.visibility = View.INVISIBLE
collapsedToolbarMessagesCountTextView.isVisible = false
areCollapsedViewsShown = false
}
// Animate expanded toolbar views
if (scrolledPercentage < ONE_HUNDRED_PERCENT) {
expandedToolbarTitleTextView.visibility = View.VISIBLE
expandedToolbarMessagesCountTextView.isVisible = messagesCount > 1
expandedToolbarTitleTextView.alpha = 1 - scrolledPercentage
expandedToolbarMessagesCountTextView.alpha = 1 - scrolledPercentage
} else {
expandedToolbarTitleTextView.visibility = View.INVISIBLE
expandedToolbarMessagesCountTextView.isVisible = false
}
ViewCompat.setElevation(appBarLayout, resources.getDimensionPixelSize(R.dimen.elevation_m).toFloat())
}
appBarLayout.addOnOffsetChangedListener(onOffsetChangedListener)
}
private fun setupLastMessageActionsListener(message: Message) {
val actionSheetTarget =
if (viewModel.isConversationEnabled() && viewModel.doesConversationHaveMoreThanOneMessage()) {
ActionSheetTarget.CONVERSATION_ITEM_IN_DETAIL_SCREEN
} else {
ActionSheetTarget.MESSAGE_ITEM_IN_DETAIL_SCREEN
}
val id = if (viewModel.isConversationEnabled() && viewModel.doesConversationHaveMoreThanOneMessage()) {
messageOrConversationId
} else {
message.messageId ?: messageOrConversationId
}
messageDetailsActionsView.setOnMoreActionClickListener {
MessageActionSheet.newInstance(
actionSheetTarget,
listOf(id),
openedFolderLocationId,
openedFolderLabelId ?: openedFolderLocationId.toString(),
getCurrentSubject(),
getMessagesFrom(message.sender?.name),
message.isStarred ?: false,
message.isScheduled,
viewModel.doesConversationHaveMoreThanOneMessage()
)
.show(supportFragmentManager, MessageActionSheet::class.qualifiedName)
}
val hasMultipleRecipients = message.toList.size + message.ccList.size > 1
val actionsUiModel = BottomActionsView.UiModel(
null,
R.drawable.ic_proton_envelope_dot,
if (viewModel.shouldShowDeleteActionInBottomActionBar()) R.drawable.ic_proton_trash_cross else R.drawable.ic_proton_trash,
R.drawable.ic_proton_tag
)
messageDetailsActionsView.bind(actionsUiModel)
messageDetailsActionsView.setAction(
BottomActionsView.ActionPosition.ACTION_FIRST, !message.isScheduled,
if (hasMultipleRecipients) R.drawable.ic_proton_arrows_up_and_left else R.drawable.ic_proton_arrow_up_and_left,
if (hasMultipleRecipients) getString(R.string.reply_all) else getString(R.string.reply)
)
messageDetailsActionsView.setAction(
BottomActionsView.ActionPosition.ACTION_THIRD, !message.isScheduled,
if (viewModel.shouldShowDeleteActionInBottomActionBar()) R.drawable.ic_proton_trash_cross else R.drawable.ic_proton_trash,
if (viewModel.shouldShowDeleteActionInBottomActionBar()) getString(R.string.delete) else getString(
R.string.trash
)
)
messageDetailsActionsView.setOnFourthActionClickListener {
showLabelsActionSheet(LabelType.MESSAGE_LABEL)
}
messageDetailsActionsView.setOnThirdActionClickListener {
if (viewModel.shouldShowDeleteActionInBottomActionBar()) {
showDeleteConfirmationDialog(
this,
getString(R.string.delete_messages),
getString(R.string.confirm_destructive_action)
) {
// Cancel observing the message/conversation in order for it not to be fetched again
// after it has been deleted from DB optimistically
viewModel.cancelConversationFlowJob()
viewModel.delete()
onBackPressed()
}
} else {
moveToTrash(message.isScheduled)
}
}
messageDetailsActionsView.setOnSecondActionClickListener {
onBackPressed()
viewModel.markUnread()
}
messageDetailsActionsView.setOnFirstActionClickListener {
executeMessageAction(
if (hasMultipleRecipients) Constants.MessageActionType.REPLY_ALL else Constants.MessageActionType.REPLY,
messageOrConversationId
)
}
}
private fun moveToTrash(isScheduled: Boolean) {
if (isScheduled) {
showTwoButtonInfoDialog(
titleStringId = R.string.scheduled_message_moved_to_trash_title,
messageStringId = R.string.scheduled_message_moved_to_trash_desc,
negativeStringId = R.string.cancel,
onPositiveButtonClicked = {
viewModel.moveToTrash()
onBackPressed()
}
)
} else {
viewModel.moveToTrash()
onBackPressed()
}
}
private fun scrollToTheExpandedMessage(messageToScrollTo: Message?) {
val messageBodyIndexToScrollTo = if (messageToScrollTo == null) {
messageExpandableAdapter.itemCount - 1
} else {
messageExpandableAdapter.visibleItems.indexOfLast {
it.message == messageToScrollTo
}
}
val messageHeaderIndexToScrollTo = messageBodyIndexToScrollTo - 1
recyclerViewLinearLayoutManager.scrollToPositionWithOffset(messageHeaderIndexToScrollTo, 0)
}
private fun showLabelsActionSheet(labelActionSheetType: LabelType = LabelType.MESSAGE_LABEL) {
LabelsActionSheet.newInstance(
messageIds = listOf(messageOrConversationId),
currentFolderLocation = openedFolderLocationId,
currentLocationId = openedFolderLabelId ?: openedFolderLocationId.toString(),
labelType = labelActionSheetType,
actionSheetTarget =
if (viewModel.isConversationEnabled()) ActionSheetTarget.CONVERSATION_ITEM_IN_DETAIL_SCREEN
else ActionSheetTarget.MESSAGE_ITEM_IN_DETAIL_SCREEN
)
.show(supportFragmentManager, LabelsActionSheet::class.qualifiedName)
}
private fun displayToolbarData(conversation: ConversationUiModel) {
starToggleButton.isChecked = conversation.isStarred
val isInvalidSubject = conversation.subject.isNullOrEmpty()
val subject = if (isInvalidSubject) getString(R.string.empty_subject) else conversation.subject
val messagesInConversation = conversation.messages.count()
val numberOfMessagesFormatted = resources.getQuantityString(
R.plurals.x_messages_count,
messagesInConversation,
messagesInConversation
)
collapsedToolbarMessagesCountTextView.text = numberOfMessagesFormatted
// Initially, the expanded message count view is shown instead.
// Visibility changes are handled by `OnOffsetChangedListener`
collapsedToolbarMessagesCountTextView.isVisible = false
expandedToolbarMessagesCountTextView.text = numberOfMessagesFormatted
expandedToolbarMessagesCountTextView.isVisible = messagesInConversation > 1
collapsedToolbarTitleTextView.text = subject
collapsedToolbarTitleTextView.visibility = View.INVISIBLE
expandedToolbarTitleTextView.text = subject
}
/**
* Legacy method to executes reply, reply_all and forward op
* @param messageOrConversationId the message or conversation ID on which to perform the action on.
* Passing a conversation ID will cause the action to be applied to the last message that is not a draft.
*/
fun executeMessageAction(
messageAction: Constants.MessageActionType,
messageOrConversationId: String?
) {
try {
val message = requireNotNull(
viewModel.decryptedConversationUiModel.value?.messages?.find {
it.messageId == messageOrConversationId
} ?: viewModel.decryptedConversationUiModel.value?.messages?.last { it.isDraft().not() }
)
val user = mUserManager.requireCurrentLegacyUser()
val userUsedSpace = user.usedSpace
val userMaxSpace = if (user.maxSpace == 0L) {
Long.MAX_VALUE
} else {
user.maxSpace
}
val percentageUsed = userUsedSpace * 100 / userMaxSpace
if (percentageUsed >= 100) {
this@MessageDetailsActivity.showTwoButtonInfoDialog(
title = getString(R.string.storage_limit_warning_title),
message = getString(R.string.storage_limit_reached_text),
positiveStringId = R.string.ok,
negativeStringId = R.string.learn_more,
onNegativeButtonClicked = {
val browserIntent = Intent(
Intent.ACTION_VIEW,
Uri.parse(getString(R.string.limit_reached_learn_more))
)
startActivity(browserIntent)
}
)
} else {
val newMessageTitle = MessageUtils.buildNewMessageTitle(
this@MessageDetailsActivity,
messageAction,
message.subject
)
viewModel.prepareEditMessageIntent(
messageAction,
message,
newMessageTitle,
getDecryptedBody(message.decryptedHTML),
mBigContentHolder
)
}
} catch (exc: Exception) {
Timber.e(exc, "Exception in reply/forward actions")
}
}
private fun observeEditMessageEvents() {
viewModel.prepareEditMessageIntent.observe(
this@MessageDetailsActivity,
Observer { editIntentExtrasEvent: Event<IntentExtrasData?> ->
val editIntentExtras = editIntentExtrasEvent.getContentIfNotHandled()
?: return@Observer
mBigContentHolder.content = editIntentExtras.mBigContentHolder.content
startComposeLauncher.launch(StartCompose.Input(intentExtrasData = editIntentExtras))
}
)
}
private fun observePermissionMissingDialogTrigger() {
viewModel.showPermissionMissingDialog.observe(this) {
DialogUtils.showInfoDialog(
context = this,
title = getString(R.string.need_permissions_title),
message = getString(R.string.need_storage_permissions_download_attachment_text),
okListener = { }
)
}
}
private fun getCurrentSubject() = expandedToolbarTitleTextView.text ?: getString(R.string.empty_subject)
private fun getMessagesFrom(messageOriginator: String?): String =
messageOriginator?.let { resources.getString(R.string.message_from, messageOriginator) } ?: EMPTY_STRING
private inner class MessageDetailsErrorObserver : Observer<Event<String>> {
override fun onChanged(status: Event<String>?) {
if (status != null) {
val content = status.getContentIfNotHandled()
if (content.isNullOrEmpty()) {
showToast(R.string.default_error_message)
} else {
showToast(content)
}
progress.visibility = View.GONE
Timber.w("MessageDetailsError, $content")
}
}
}
private fun onLoadEmbeddedImagesClicked(message: Message, embeddedImageIds: List<String>) {
// this will ensure that the message has been loaded
// and will protect from premature clicking on download attachments button
if (viewModel.renderingPassed) {
viewModel.startDownloadEmbeddedImagesJob(message, embeddedImageIds)
}
}
private fun onDisplayRemoteContentClicked(message: Message) {
viewModel.displayRemoteContent(message)
viewModel.checkStoragePermission.observe(this, { storagePermissionHelper.checkPermission() })
}
private fun onEditDraftClicked(message: Message) {
val intent = AppUtil.decorInAppIntent(Intent(this, ComposeMessageActivity::class.java))
intent.putExtra(ComposeMessageActivity.EXTRA_MESSAGE_ID, message.messageId)
intent.putExtra(ComposeMessageActivity.EXTRA_MESSAGE_RESPONSE_INLINE, message.isInline)
intent.putExtra(ComposeMessageActivity.EXTRA_MESSAGE_ADDRESS_ID, message.addressID)
startActivity(intent)
}
private fun onDownloadAttachment(attachment: Attachment) {
attachmentToDownload.set(attachment)
storagePermissionHelper.checkPermission()
}
private fun onReplyMessageClicked(messageAction: Constants.MessageActionType, message: Message) {
executeMessageAction(messageAction, message.messageId)
}
private fun onShowMessageActionSheet(message: Message) {
MessageActionSheet.newInstance(
ActionSheetTarget.MESSAGE_ITEM_WITHIN_CONVERSATION_DETAIL_SCREEN,
listOf(message.messageId ?: messageOrConversationId),
message.location,
openedFolderLabelId ?: message.location.toString(),
getCurrentSubject(),
getMessagesFrom(message.sender?.name),
message.isStarred ?: false,
message.isScheduled
).show(supportFragmentManager, MessageActionSheet::class.qualifiedName)
}
fun printMessage(messageId: String) {
viewModel.printMessage(messageId, primaryBaseActivity)
}
class Launcher : ActivityResultContract<Input, Unit>() {
override fun createIntent(context: Context, input: Input): Intent =
input.toIntent(context)
override fun parseResult(resultCode: Int, intent: Intent?) {}
}
data class Input(
val messageId: String,
val locationType: Constants.MessageLocationType?,
val labelId: LabelId?,
val messageSubject: String?
) {
fun toIntent(context: Context) = Intent(context, MessageDetailsActivity::class.java)
.putExtra(EXTRA_MESSAGE_OR_CONVERSATION_ID, messageId)
.putExtra(EXTRA_MESSAGE_LOCATION_ID, locationType?.messageLocationTypeValue)
.putExtra(EXTRA_MAILBOX_LABEL_ID, labelId?.id)
.putExtra(EXTRA_MESSAGE_SUBJECT, messageSubject)
}
companion object {
const val EXTRA_MESSAGE_OR_CONVERSATION_ID = "messageOrConversationId"
const val EXTRA_MESSAGE_LOCATION_ID = "messageOrConversationLocation"
const val EXTRA_MAILBOX_LABEL_ID = "mailboxLabelId"
const val EXTRA_MESSAGE_RECIPIENT_USER_ID = "message_recipient_user_id"
const val EXTRA_MESSAGE_RECIPIENT_USERNAME = "message_recipient_username"
const val EXTRA_MESSAGE_SUBJECT = "message_subject"
}
}