0
0
mirror of https://github.com/thunderbird/thunderbird-android.git synced 2024-09-20 12:12:15 +02:00

Display crypto status in message details bottom sheet

This commit is contained in:
cketti 2023-01-31 12:56:12 +01:00
parent cbf3ef985d
commit 80c68e1e3e
8 changed files with 254 additions and 1 deletions

View File

@ -0,0 +1,52 @@
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
}
}
}

View File

@ -1,5 +1,6 @@
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()

View File

@ -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?

View File

@ -1,5 +1,6 @@
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
}

View File

@ -38,6 +38,10 @@ public class MessageCryptoPresenter implements OnCryptoClickListener {
this.messageCryptoMvpView = messageCryptoMvpView;
}
public CryptoResultAnnotation getCryptoResultAnnotation() {
return cryptoResultAnnotation;
}
public void onResume() {
if (reloadOnResumeWithoutRecreateFlag) {
reloadOnResumeWithoutRecreateFlag = false;

View File

@ -21,6 +21,7 @@ import androidx.core.app.ActivityCompat
import androidx.core.content.withStyledAttributes
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResultListener
import com.fsck.k9.Account
import com.fsck.k9.K9
import com.fsck.k9.activity.MessageCompose
@ -134,6 +135,8 @@ class MessageViewFragment :
fragmentManager = parentFragmentManager,
callback = messageLoaderCallbacks
)
setFragmentResultListener(MessageDetailsFragment.FRAGMENT_RESULT_KEY, ::onMessageDetailsResult)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
@ -384,7 +387,8 @@ class MessageViewFragment :
private val messageHeaderClickListener = object : MessageHeaderClickListener {
override fun onParticipantsContainerClick() {
val messageDetailsFragment = MessageDetailsFragment.create(messageReference)
messageDetailsFragment.show(childFragmentManager, "message_details")
messageDetailsFragment.cryptoResult = messageCryptoPresenter.cryptoResultAnnotation
messageDetailsFragment.show(parentFragmentManager, "message_details")
}
override fun onMenuItemClick(itemId: Int) {
@ -578,6 +582,20 @@ class MessageViewFragment :
}
}
private fun onMessageDetailsResult(requestKey: String, result: Bundle) {
when (val action = result.getString(MessageDetailsFragment.RESULT_ACTION)) {
MessageDetailsFragment.ACTION_SEARCH_KEYS -> {
onClickSearchKey()
}
MessageDetailsFragment.ACTION_SHOW_WARNING -> {
onClickShowSecurityWarning()
}
else -> {
error("Unsupported action: $action")
}
}
}
private fun onCreateDocumentResult(data: Intent?) {
if (data != null && data.data != null) {
createAttachmentController(currentAttachmentViewInfo).saveAttachmentTo(data.data)

View File

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/keyline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_begin="72dp" />
<ImageView
android:id="@+id/crypto_status_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:contentDescription="@null"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/keyline"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@drawable/status_lock_dots_3"
tools:tint="?attr/openpgp_green" />
<TextView
android:id="@+id/crypto_status_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:textAppearance="?attr/textAppearanceBody2"
app:layout_constraintBottom_toTopOf="@+id/crypto_status_description"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="@+id/keyline"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="@string/crypto_msg_title_encrypted_signed_e2e" />
<TextView
android:id="@+id/crypto_status_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:textAppearance="?attr/textAppearanceBody2"
android:textColor="?android:attr/textColorSecondary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="@+id/keyline"
app:layout_constraintTop_toBottomOf="@+id/crypto_status_title"
tools:text="@string/crypto_msg_encrypted_sign_verified" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item type="id" name="message_details_date"/>
<item type="id" name="message_details_crypto_status"/>
<item type="id" name="message_details_section_header"/>
<item type="id" name="message_details_participant"/>
<item type="id" name="message_details_divider"/>