Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 80c68e1e authored by cketti's avatar cketti
Browse files

Display crypto status in message details bottom sheet

parent cbf3ef98
Loading
Loading
Loading
Loading
+52 −0
Original line number Diff line number Diff line
package com.fsck.k9.ui.messagedetails

import android.content.res.ColorStateList
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import com.fsck.k9.ui.R
import com.fsck.k9.ui.resolveColorAttribute
import com.mikepenz.fastadapter.FastAdapter
import com.mikepenz.fastadapter.items.AbstractItem

internal class CryptoStatusItem(val cryptoDetails: CryptoDetails) : AbstractItem<CryptoStatusItem.ViewHolder>() {
    override val type = R.id.message_details_crypto_status
    override val layoutRes = R.layout.message_details_crypto_status_item

    override fun getViewHolder(v: View) = ViewHolder(v)

    class ViewHolder(view: View) : FastAdapter.ViewHolder<CryptoStatusItem>(view) {
        private val titleTextView = view.findViewById<TextView>(R.id.crypto_status_title)
        private val descriptionTextView = view.findViewById<TextView>(R.id.crypto_status_description)
        private val imageView = view.findViewById<ImageView>(R.id.crypto_status_icon)
        private val originalBackground = view.background

        override fun bindView(item: CryptoStatusItem, payloads: List<Any>) {
            val context = itemView.context
            val cryptoDetails = item.cryptoDetails
            val cryptoStatus = cryptoDetails.cryptoStatus

            imageView.setImageResource(cryptoStatus.statusIconRes)
            val tintColor = context.theme.resolveColorAttribute(cryptoStatus.colorAttr)
            imageView.imageTintList = ColorStateList.valueOf(tintColor)

            cryptoStatus.titleTextRes?.let { stringResId ->
                titleTextView.text = context.getString(stringResId)
            }
            cryptoStatus.descriptionTextRes?.let { stringResId ->
                descriptionTextView.text = context.getString(stringResId)
            }

            if (!cryptoDetails.isClickable) {
                itemView.background = null
            }
        }

        override fun unbindView(item: CryptoStatusItem) {
            imageView.setImageDrawable(null)
            titleTextView.text = null
            descriptionTextView.text = null
            itemView.background = originalBackground
        }
    }
}
+64 −0
Original line number Diff line number Diff line
package com.fsck.k9.ui.messagedetails

import android.app.PendingIntent
import android.content.Intent
import android.net.Uri
import android.os.Bundle
@@ -11,13 +12,16 @@ import android.widget.ProgressBar
import androidx.annotation.StringRes
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.core.view.isVisible
import androidx.fragment.app.setFragmentResult
import androidx.recyclerview.widget.RecyclerView
import app.k9mail.ui.utils.bottomsheet.ToolbarBottomSheetDialogFragment
import com.fsck.k9.activity.MessageCompose
import com.fsck.k9.contacts.ContactPictureLoader
import com.fsck.k9.controller.MessageReference
import com.fsck.k9.mail.Address
import com.fsck.k9.mailstore.CryptoResultAnnotation
import com.fsck.k9.ui.R
import com.fsck.k9.ui.observe
import com.fsck.k9.ui.withArguments
@@ -36,6 +40,9 @@ class MessageDetailsFragment : ToolbarBottomSheetDialogFragment() {

    private lateinit var messageReference: MessageReference

    // FIXME: Replace this with a mechanism that survives process death
    var cryptoResult: CryptoResultAnnotation? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

@@ -54,6 +61,10 @@ class MessageDetailsFragment : ToolbarBottomSheetDialogFragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        cryptoResult?.let {
            viewModel.cryptoResult = it
        }

        val dialog = checkNotNull(dialog)
        dialog.isDismissWithAnimation = true

@@ -71,6 +82,14 @@ class MessageDetailsFragment : ToolbarBottomSheetDialogFragment() {
        val errorView = view.findViewById<View>(R.id.message_details_error)
        val recyclerView = view.findViewById<RecyclerView>(R.id.message_details_list)

        viewModel.uiEvents.observe(this) { event ->
            when (event) {
                is MessageDetailEvent.ShowCryptoKeys -> showCryptoKeys(event.pendingIntent)
                MessageDetailEvent.SearchCryptoKeys -> searchCryptoKeys()
                MessageDetailEvent.ShowCryptoWarning -> showCryptoWarning()
            }
        }

        viewModel.loadData(messageReference).observe(this) { state ->
            when (state) {
                MessageDetailsState.Loading -> {
@@ -97,6 +116,10 @@ class MessageDetailsFragment : ToolbarBottomSheetDialogFragment() {
        val itemAdapter = ItemAdapter<GenericItem>().apply {
            add(MessageDateItem(details.date ?: getString(R.string.message_details_missing_date)))

            if (details.cryptoDetails != null) {
                add(CryptoStatusItem(details.cryptoDetails))
            }

            addParticipants(details.from, R.string.message_details_from_section_title, showContactPicture)
            addParticipants(details.sender, R.string.message_details_sender_section_title, showContactPicture)
            addParticipants(details.replyTo, R.string.message_details_replyto_section_title, showContactPicture)
@@ -109,6 +132,7 @@ class MessageDetailsFragment : ToolbarBottomSheetDialogFragment() {
        }

        val adapter = FastAdapter.with(itemAdapter).apply {
            addEventHook(cryptoStatusClickEventHook)
            addEventHook(participantClickEventHook)
            addEventHook(addToContactsClickEventHook)
            addEventHook(composeClickEventHook)
@@ -133,6 +157,27 @@ class MessageDetailsFragment : ToolbarBottomSheetDialogFragment() {
        }
    }

    private val cryptoStatusClickEventHook = object : ClickEventHook<CryptoStatusItem>() {
        override fun onBind(viewHolder: RecyclerView.ViewHolder): View? {
            return if (viewHolder is CryptoStatusItem.ViewHolder) {
                viewHolder.itemView
            } else {
                null
            }
        }

        override fun onClick(
            v: View,
            position: Int,
            fastAdapter: FastAdapter<CryptoStatusItem>,
            item: CryptoStatusItem
        ) {
            if (item.cryptoDetails.isClickable) {
                viewModel.onCryptoStatusClicked()
            }
        }
    }

    private val participantClickEventHook = object : ClickEventHook<ParticipantItem>() {
        override fun onBind(viewHolder: RecyclerView.ViewHolder): View? {
            return if (viewHolder is ParticipantItem.ViewHolder) {
@@ -237,9 +282,28 @@ class MessageDetailsFragment : ToolbarBottomSheetDialogFragment() {
        }
    }

    private fun showCryptoKeys(pendingIntent: PendingIntent) {
        requireActivity().startIntentSender(pendingIntent.intentSender, null, 0, 0, 0)
    }

    private fun searchCryptoKeys() {
        setFragmentResult(FRAGMENT_RESULT_KEY, bundleOf(RESULT_ACTION to ACTION_SEARCH_KEYS))
        dismiss()
    }

    private fun showCryptoWarning() {
        setFragmentResult(FRAGMENT_RESULT_KEY, bundleOf(RESULT_ACTION to ACTION_SHOW_WARNING))
        dismiss()
    }

    companion object {
        private const val ARG_REFERENCE = "reference"

        const val FRAGMENT_RESULT_KEY = "messageDetailsResult"
        const val RESULT_ACTION = "action"
        const val ACTION_SEARCH_KEYS = "search_keys"
        const val ACTION_SHOW_WARNING = "show_warning"

        fun create(messageReference: MessageReference): MessageDetailsFragment {
            return MessageDetailsFragment().withArguments(
                ARG_REFERENCE to messageReference.toIdentityString()
+7 −0
Original line number Diff line number Diff line
@@ -2,9 +2,11 @@ package com.fsck.k9.ui.messagedetails

import android.net.Uri
import com.fsck.k9.mail.Address
import com.fsck.k9.view.MessageCryptoDisplayStatus

data class MessageDetailsUi(
    val date: String?,
    val cryptoDetails: CryptoDetails?,
    val from: List<Participant>,
    val sender: List<Participant>,
    val replyTo: List<Participant>,
@@ -13,6 +15,11 @@ data class MessageDetailsUi(
    val bcc: List<Participant>
)

data class CryptoDetails(
    val cryptoStatus: MessageCryptoDisplayStatus,
    val isClickable: Boolean
)

data class Participant(
    val address: Address,
    val contactLookupUri: Uri?
+47 −0
Original line number Diff line number Diff line
package com.fsck.k9.ui.messagedetails

import android.app.PendingIntent
import android.content.res.Resources
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -7,14 +8,18 @@ import com.fsck.k9.controller.MessageReference
import com.fsck.k9.helper.ClipboardManager
import com.fsck.k9.helper.Contacts
import com.fsck.k9.mail.Address
import com.fsck.k9.mailstore.CryptoResultAnnotation
import com.fsck.k9.mailstore.MessageDate
import com.fsck.k9.mailstore.MessageRepository
import com.fsck.k9.ui.R
import com.fsck.k9.view.MessageCryptoDisplayStatus
import java.text.DateFormat
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch

internal class MessageDetailsViewModel(
@@ -26,6 +31,10 @@ internal class MessageDetailsViewModel(
) : ViewModel() {
    private val dateFormat = DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.MEDIUM, Locale.getDefault())
    private val uiState = MutableStateFlow<MessageDetailsState>(MessageDetailsState.Loading)
    private val eventChannel = Channel<MessageDetailEvent>()

    val uiEvents = eventChannel.receiveAsFlow()
    var cryptoResult: CryptoResultAnnotation? = null

    fun loadData(messageReference: MessageReference): StateFlow<MessageDetailsState> {
        viewModelScope.launch(Dispatchers.IO) {
@@ -35,6 +44,7 @@ internal class MessageDetailsViewModel(
                val senderList = messageDetails.sender?.let { listOf(it) } ?: emptyList()
                val messageDetailsUi = MessageDetailsUi(
                    date = buildDisplayDate(messageDetails.date),
                    cryptoDetails = cryptoResult?.toCryptoDetails(),
                    from = messageDetails.from.toParticipants(),
                    sender = senderList.toParticipants(),
                    replyTo = messageDetails.replyTo.toParticipants(),
@@ -63,6 +73,15 @@ internal class MessageDetailsViewModel(
        }
    }

    private fun CryptoResultAnnotation.toCryptoDetails(): CryptoDetails {
        val messageCryptoDisplayStatus = MessageCryptoDisplayStatus.fromResultAnnotation(this)
        return CryptoDetails(
            cryptoStatus = messageCryptoDisplayStatus,
            isClickable = messageCryptoDisplayStatus.hasAssociatedKey() || messageCryptoDisplayStatus.isUnknownKey ||
                hasOpenPgpInsecureWarningPendingIntent()
        )
    }

    private fun List<Address>.toParticipants(): List<Participant> {
        return this.map { address ->
            Participant(
@@ -72,6 +91,28 @@ internal class MessageDetailsViewModel(
        }
    }

    fun onCryptoStatusClicked() {
        val cryptoResult = cryptoResult ?: return
        val cryptoStatus = MessageCryptoDisplayStatus.fromResultAnnotation(cryptoResult)

        if (cryptoStatus.hasAssociatedKey()) {
            val pendingIntent = cryptoResult.openPgpSigningKeyIntentIfAny
            if (pendingIntent != null) {
                viewModelScope.launch {
                    eventChannel.send(MessageDetailEvent.ShowCryptoKeys(pendingIntent))
                }
            }
        } else if (cryptoStatus.isUnknownKey) {
            viewModelScope.launch {
                eventChannel.send(MessageDetailEvent.SearchCryptoKeys)
            }
        } else if (cryptoResult.hasOpenPgpInsecureWarningPendingIntent()) {
            viewModelScope.launch {
                eventChannel.send(MessageDetailEvent.ShowCryptoWarning)
            }
        }
    }

    fun onCopyEmailAddressToClipboard(participant: Participant) {
        val label = resources.getString(R.string.clipboard_label_email_address)
        val emailAddress = participant.address.address
@@ -93,3 +134,9 @@ sealed interface MessageDetailsState {
        val details: MessageDetailsUi
    ) : MessageDetailsState
}

sealed interface MessageDetailEvent {
    data class ShowCryptoKeys(val pendingIntent: PendingIntent) : MessageDetailEvent
    object SearchCryptoKeys : MessageDetailEvent
    object ShowCryptoWarning : MessageDetailEvent
}
+4 −0
Original line number Diff line number Diff line
@@ -38,6 +38,10 @@ public class MessageCryptoPresenter implements OnCryptoClickListener {
        this.messageCryptoMvpView = messageCryptoMvpView;
    }

    public CryptoResultAnnotation getCryptoResultAnnotation() {
        return cryptoResultAnnotation;
    }

    public void onResume() {
        if (reloadOnResumeWithoutRecreateFlag) {
            reloadOnResumeWithoutRecreateFlag = false;
Loading