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

Unverified Commit 77790891 authored by cketti's avatar cketti Committed by GitHub
Browse files

Merge pull request #6616 from thundernest/crypto_status

Message View Redesign: Display crypto status
parents cbf3ef98 677ef154
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
}
+0 −140
Original line number Diff line number Diff line
package com.fsck.k9.ui.messageview;


import android.annotation.SuppressLint;
import android.app.AlertDialog;
import android.app.AlertDialog.Builder;
import android.app.Dialog;
import android.content.DialogInterface;
import android.content.DialogInterface.OnClickListener;
import android.os.Bundle;
import androidx.annotation.AttrRes;
import androidx.annotation.ColorInt;
import androidx.annotation.DrawableRes;
import androidx.annotation.StringRes;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.Fragment;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ImageView;
import android.widget.TextView;

import com.fsck.k9.ui.R;
import com.fsck.k9.view.MessageCryptoDisplayStatus;
import com.fsck.k9.view.ThemeUtils;


public class CryptoInfoDialog extends DialogFragment {
    public static final String ARG_DISPLAY_STATUS = "display_status";
    public static final String ARG_HAS_SECURITY_WARNING = "has_security_warning";


    private ImageView statusIcon;
    private TextView titleText;
    private TextView descriptionText;


    public static CryptoInfoDialog newInstance(MessageCryptoDisplayStatus displayStatus, boolean hasSecurityWarning) {
        CryptoInfoDialog frag = new CryptoInfoDialog();

        Bundle args = new Bundle();
        args.putString(ARG_DISPLAY_STATUS, displayStatus.toString());
        args.putBoolean(ARG_HAS_SECURITY_WARNING, hasSecurityWarning);
        frag.setArguments(args);

        return frag;
    }

    @SuppressLint("InflateParams") // inflating without root element is fine for creating a dialog
    @Override
    public Dialog onCreateDialog(Bundle savedInstanceState) {
        Builder b = new AlertDialog.Builder(getActivity());

        View dialogView = LayoutInflater.from(getActivity()).inflate(R.layout.message_crypto_info_dialog, null);

        statusIcon = dialogView.findViewById(R.id.crypto_info_top_icon_1);
        titleText = dialogView.findViewById(R.id.crypto_info_title);
        descriptionText = dialogView.findViewById(R.id.crypto_info_text);

        MessageCryptoDisplayStatus displayStatus =
                MessageCryptoDisplayStatus.valueOf(getArguments().getString(ARG_DISPLAY_STATUS));
        setMessageForDisplayStatus(displayStatus);

        b.setView(dialogView);
        b.setPositiveButton(R.string.crypto_info_ok, new OnClickListener() {
            @Override
            public void onClick(DialogInterface dialogInterface, int i) {
                dismiss();
            }
        });
        boolean hasSecurityWarning = getArguments().getBoolean(ARG_HAS_SECURITY_WARNING);
        if (hasSecurityWarning) {
            b.setNeutralButton(R.string.crypto_info_view_security_warning, new OnClickListener() {
                @Override
                public void onClick(DialogInterface dialogInterface, int i) {
                    Fragment frag = getTargetFragment();
                    if (!(frag instanceof OnClickShowCryptoKeyListener)) {
                        throw new AssertionError("Displaying activity must implement OnClickShowCryptoKeyListener!");
                    }
                    ((OnClickShowCryptoKeyListener) frag).onClickShowSecurityWarning();
                }
            });
        } else if (displayStatus.isUnknownKey()) {
            b.setNeutralButton(R.string.crypto_info_search_key, new OnClickListener() {
                @Override
                public void onClick(DialogInterface dialogInterface, int i) {
                    Fragment frag = getTargetFragment();
                    if (! (frag instanceof OnClickShowCryptoKeyListener)) {
                        throw new AssertionError("Displaying activity must implement OnClickShowCryptoKeyListener!");
                    }
                    ((OnClickShowCryptoKeyListener) frag).onClickSearchKey();
                }
            });
        } else if (displayStatus.hasAssociatedKey()) {
            int buttonLabel = displayStatus.isUnencryptedSigned() ?
                    R.string.crypto_info_view_signer : R.string.crypto_info_view_sender;
            b.setNeutralButton(buttonLabel, new OnClickListener() {
                @Override
                public void onClick(DialogInterface dialogInterface, int i) {
                    Fragment frag = getTargetFragment();
                    if (! (frag instanceof OnClickShowCryptoKeyListener)) {
                        throw new AssertionError("Displaying activity must implement OnClickShowCryptoKeyListener!");
                    }
                    ((OnClickShowCryptoKeyListener) frag).onClickShowCryptoKey();
                }
            });
        }

        return b.create();
    }

    private void setMessageForDisplayStatus(MessageCryptoDisplayStatus displayStatus) {
        if (displayStatus.getTitleTextRes() == null) {
            throw new AssertionError("Crypto info dialog can only be displayed for items with text!");
        }

        setMessageSingleLine(displayStatus.getColorAttr(), displayStatus.getTitleTextRes(),
                displayStatus.getDescriptionTextRes(), displayStatus.getStatusIconRes());
    }

    private void setMessageSingleLine(@AttrRes int colorAttr, @StringRes int titleTextRes,
            @StringRes Integer descTextRes, @DrawableRes int statusIconRes) {
        @ColorInt int color = ThemeUtils.getStyledColor(getActivity(), colorAttr);

        statusIcon.setImageResource(statusIconRes);
        statusIcon.setColorFilter(color);
        titleText.setText(titleTextRes);
        if (descTextRes != null) {
            descriptionText.setText(descTextRes);
            descriptionText.setVisibility(View.VISIBLE);
        } else {
            descriptionText.setVisibility(View.GONE);
        }
    }

    public interface OnClickShowCryptoKeyListener {
        void onClickShowCryptoKey();
        void onClickShowSecurityWarning();
        void onClickSearchKey();
    }
}
Loading