Load message details

This commit is contained in:
cketti 2023-01-18 17:13:44 +01:00
parent 7dd0ed79c4
commit 9cb6859ad6
35 changed files with 972 additions and 150 deletions

View file

@ -25,6 +25,7 @@ dependencies {
implementation libs.moshi implementation libs.moshi
implementation libs.timber implementation libs.timber
implementation libs.mime4j.core implementation libs.mime4j.core
implementation libs.mime4j.dom
testImplementation project(':mail:testing') testImplementation project(':mail:testing')
testImplementation project(":backend:imap") testImplementation project(":backend:imap")

View file

@ -1,6 +1,8 @@
package com.fsck.k9.helper; package com.fsck.k9.helper;
import java.util.HashMap;
import android.Manifest; import android.Manifest;
import android.content.ContentResolver; import android.content.ContentResolver;
import android.content.Context; import android.content.Context;
@ -9,13 +11,12 @@ import android.content.pm.PackageManager;
import android.database.Cursor; import android.database.Cursor;
import android.net.Uri; import android.net.Uri;
import android.provider.ContactsContract; import android.provider.ContactsContract;
import timber.log.Timber;
import android.provider.ContactsContract.CommonDataKinds.Photo; import android.provider.ContactsContract.CommonDataKinds.Photo;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import com.fsck.k9.mail.Address; import com.fsck.k9.mail.Address;
import timber.log.Timber;
import java.util.HashMap;
/** /**
* Helper class to access the contacts stored on the device. * Helper class to access the contacts stored on the device.
@ -37,7 +38,8 @@ public class Contacts {
ContactsContract.CommonDataKinds.Email._ID, ContactsContract.CommonDataKinds.Email._ID,
ContactsContract.Contacts.DISPLAY_NAME, ContactsContract.Contacts.DISPLAY_NAME,
ContactsContract.CommonDataKinds.Email.CONTACT_ID, ContactsContract.CommonDataKinds.Email.CONTACT_ID,
Photo.PHOTO_URI Photo.PHOTO_URI,
ContactsContract.Contacts.LOOKUP_KEY
}; };
/** /**
@ -52,6 +54,8 @@ public class Contacts {
*/ */
protected static final int CONTACT_ID_INDEX = 2; protected static final int CONTACT_ID_INDEX = 2;
protected static final int LOOKUP_KEY_INDEX = 4;
/** /**
* Get instance of the Contacts class. * Get instance of the Contacts class.
@ -169,6 +173,24 @@ public class Contacts {
return false; return false;
} }
@Nullable
public Uri getContactUri(String emailAddress) {
Cursor cursor = getContactByAddress(emailAddress);
if (cursor == null) {
return null;
}
try (cursor) {
if (!cursor.moveToFirst()) {
return null;
}
long contactId = cursor.getLong(CONTACT_ID_INDEX);
String lookupKey = cursor.getString(LOOKUP_KEY_INDEX);
return ContactsContract.Contacts.getLookupUri(contactId, lookupKey);
}
}
/** /**
* Get the name of the contact an email address belongs to. * Get the name of the contact an email address belongs to.
* *

View file

@ -0,0 +1,22 @@
package com.fsck.k9.mailstore
import com.fsck.k9.mail.Address
import java.util.Date
data class MessageDetails(
val date: MessageDate,
val from: List<Address>,
val sender: Address?,
val replyTo: List<Address>,
val to: List<Address>,
val cc: List<Address>,
val bcc: List<Address>
)
sealed interface MessageDate {
data class ValidDate(val date: Date) : MessageDate
data class InvalidDate(val dateHeader: String) : MessageDate
object MissingDate : MessageDate
}

View file

@ -1,11 +1,69 @@
package com.fsck.k9.mailstore package com.fsck.k9.mailstore
import com.fsck.k9.controller.MessageReference import com.fsck.k9.controller.MessageReference
import com.fsck.k9.mail.Address
import com.fsck.k9.mail.Header import com.fsck.k9.mail.Header
import com.fsck.k9.mail.internet.MimeUtility
import org.apache.james.mime4j.dom.field.DateTimeField
import org.apache.james.mime4j.field.DefaultFieldParser
class MessageRepository(private val messageStoreManager: MessageStoreManager) { class MessageRepository(private val messageStoreManager: MessageStoreManager) {
fun getHeaders(messageReference: MessageReference): List<Header> { fun getHeaders(messageReference: MessageReference): List<Header> {
val messageStore = messageStoreManager.getMessageStore(messageReference.accountUuid) val messageStore = messageStoreManager.getMessageStore(messageReference.accountUuid)
return messageStore.getHeaders(messageReference.folderId, messageReference.uid) return messageStore.getHeaders(messageReference.folderId, messageReference.uid)
} }
fun getMessageDetails(messageReference: MessageReference): MessageDetails {
val messageStore = messageStoreManager.getMessageStore(messageReference.accountUuid)
val headers = messageStore.getHeaders(messageReference.folderId, messageReference.uid, MESSAGE_DETAILS_HEADERS)
val messageDate = headers.parseDate("date")
val fromAddresses = headers.parseAddresses("from")
val senderAddresses = headers.parseAddresses("sender")
val replyToAddresses = headers.parseAddresses("reply-to")
val toAddresses = headers.parseAddresses("to")
val ccAddresses = headers.parseAddresses("cc")
val bccAddresses = headers.parseAddresses("bcc")
return MessageDetails(
date = messageDate,
from = fromAddresses,
sender = senderAddresses.firstOrNull(),
replyTo = replyToAddresses,
to = toAddresses,
cc = ccAddresses,
bcc = bccAddresses
)
}
private fun List<Header>.firstHeaderOrNull(name: String): String? {
return firstOrNull { it.name.equals(name, ignoreCase = true) }?.value
}
private fun List<Header>.parseAddresses(headerName: String): List<Address> {
return Address.parse(MimeUtility.unfold(firstHeaderOrNull(headerName))).toList()
}
private fun List<Header>.parseDate(headerName: String): MessageDate {
val dateHeader = firstHeaderOrNull(headerName) ?: return MessageDate.MissingDate
return try {
val dateTimeField = DefaultFieldParser.parse("Date: $dateHeader") as DateTimeField
return MessageDate.ValidDate(date = dateTimeField.date)
} catch (e: Exception) {
MessageDate.InvalidDate(dateHeader)
}
}
companion object {
private val MESSAGE_DETAILS_HEADERS = setOf(
"date",
"from",
"sender",
"reply-to",
"to",
"cc",
"bcc",
)
}
} }

View file

@ -155,6 +155,11 @@ interface MessageStore {
*/ */
fun getHeaders(folderId: Long, messageServerId: String): List<Header> fun getHeaders(folderId: Long, messageServerId: String): List<Header>
/**
* Retrieve selected header fields of a message.
*/
fun getHeaders(folderId: Long, messageServerId: String, headerNames: Set<String>): List<Header>
/** /**
* Return the size of this message store in bytes. * Return the size of this message store in bytes.
*/ */

View file

@ -132,6 +132,10 @@ class K9MessageStore(
return retrieveMessageOperations.getHeaders(folderId, messageServerId) return retrieveMessageOperations.getHeaders(folderId, messageServerId)
} }
override fun getHeaders(folderId: Long, messageServerId: String, headerNames: Set<String>): List<Header> {
return retrieveMessageOperations.getHeaders(folderId, messageServerId, headerNames)
}
override fun destroyMessages(folderId: Long, messageServerIds: Collection<String>) { override fun destroyMessages(folderId: Long, messageServerIds: Collection<String>) {
deleteMessageOperations.destroyMessages(folderId, messageServerIds) deleteMessageOperations.destroyMessages(folderId, messageServerIds)
} }

View file

@ -2,6 +2,7 @@ package com.fsck.k9.storage.messages
import androidx.core.database.getLongOrNull import androidx.core.database.getLongOrNull
import com.fsck.k9.K9 import com.fsck.k9.K9
import com.fsck.k9.helper.mapToSet
import com.fsck.k9.mail.Flag import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.Header import com.fsck.k9.mail.Header
import com.fsck.k9.mail.internet.MimeHeader import com.fsck.k9.mail.internet.MimeHeader
@ -167,7 +168,7 @@ internal class RetrieveMessageOperations(private val lockableDatabase: LockableD
} }
} }
fun getHeaders(folderId: Long, messageServerId: String): List<Header> { fun getHeaders(folderId: Long, messageServerId: String, headerNames: Set<String>? = null): List<Header> {
return lockableDatabase.execute(false) { database -> return lockableDatabase.execute(false) { database ->
database.rawQuery( database.rawQuery(
"SELECT message_parts.header FROM messages" + "SELECT message_parts.header FROM messages" +
@ -178,10 +179,13 @@ internal class RetrieveMessageOperations(private val lockableDatabase: LockableD
if (!cursor.moveToFirst()) throw MessageNotFoundException(folderId, messageServerId) if (!cursor.moveToFirst()) throw MessageNotFoundException(folderId, messageServerId)
val headerBytes = cursor.getBlob(0) val headerBytes = cursor.getBlob(0)
val lowercaseHeaderNames = headerNames?.mapToSet(headerNames.size) { it.lowercase() }
val header = MimeHeader() val header = MimeHeader()
MessageHeaderParser.parse(headerBytes.inputStream()) { name, value -> MessageHeaderParser.parse(headerBytes.inputStream()) { name, value ->
header.addRawHeader(name, value) if (lowercaseHeaderNames == null || name.lowercase() in lowercaseHeaderNames) {
header.addRawHeader(name, value)
}
} }
header.headers header.headers

View file

@ -170,6 +170,34 @@ class RetrieveMessageOperationsTest : RobolectricTest() {
) )
} }
@Test
fun `get some headers`() {
val messagePartId = sqliteDatabase.createMessagePart(
header = """
From: <alice@domain.example>
To: Bob <bob@domain.example>
Date: Thu, 01 Apr 2021 01:23:45 +0200
Subject: Test
Message-Id: <20210401012345.123456789A@domain.example>
""".trimIndent().crlf()
)
sqliteDatabase.createMessage(folderId = 1, uid = "uid1", messagePartId = messagePartId)
val headers = retrieveMessageOperations.getHeaders(
folderId = 1,
messageServerId = "uid1",
headerNames = setOf("from", "to", "message-id")
)
assertThat(headers).isEqualTo(
listOf(
Header("From", "<alice@domain.example>"),
Header("To", "Bob <bob@domain.example>"),
Header("Message-Id", "<20210401012345.123456789A@domain.example>")
)
)
}
@Test @Test
fun `get oldest message date`() { fun `get oldest message date`() {
sqliteDatabase.createMessage(folderId = 1, date = 42) sqliteDatabase.createMessage(folderId = 1, date = 42)

View file

@ -11,6 +11,7 @@ import com.fsck.k9.ui.choosefolder.chooseFolderUiModule
import com.fsck.k9.ui.endtoend.endToEndUiModule import com.fsck.k9.ui.endtoend.endToEndUiModule
import com.fsck.k9.ui.folders.foldersUiModule import com.fsck.k9.ui.folders.foldersUiModule
import com.fsck.k9.ui.managefolders.manageFoldersUiModule import com.fsck.k9.ui.managefolders.manageFoldersUiModule
import com.fsck.k9.ui.messagedetails.messageDetailsUiModule
import com.fsck.k9.ui.messagelist.messageListUiModule import com.fsck.k9.ui.messagelist.messageListUiModule
import com.fsck.k9.ui.messagesource.messageSourceModule import com.fsck.k9.ui.messagesource.messageSourceModule
import com.fsck.k9.ui.settings.settingsUiModule import com.fsck.k9.ui.settings.settingsUiModule
@ -33,5 +34,6 @@ val uiModules = listOf(
viewModule, viewModule,
changelogUiModule, changelogUiModule,
messageSourceModule, messageSourceModule,
accountUiModule accountUiModule,
messageDetailsUiModule
) )

View file

@ -0,0 +1,23 @@
package com.fsck.k9.ui.messagedetails
import android.content.Context
import android.content.Intent
import android.provider.ContactsContract
internal class AddToContactsLauncher {
fun launch(context: Context, name: String?, email: String) {
val intent = Intent(Intent.ACTION_INSERT).apply {
type = ContactsContract.Contacts.CONTENT_TYPE
putExtra(ContactsContract.Intents.Insert.EMAIL, email)
if (name != null) {
putExtra(ContactsContract.Intents.Insert.NAME, name)
}
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NEW_DOCUMENT
}
context.startActivity(intent)
}
}

View file

@ -0,0 +1,8 @@
package com.fsck.k9.ui.messagedetails
import com.fsck.k9.K9
class ContactSettingsProvider {
val isShowContactPicture: Boolean
get() = K9.isShowContactPicture
}

View file

@ -0,0 +1,19 @@
package com.fsck.k9.ui.messagedetails
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
val messageDetailsUiModule = module {
viewModel {
MessageDetailsViewModel(
resources = get(),
messageRepository = get(),
contactSettingsProvider = get(),
contacts = get(),
clipboardManager = get()
)
}
factory { ContactSettingsProvider() }
factory { AddToContactsLauncher() }
factory { ShowContactLauncher() }
}

View file

@ -0,0 +1,26 @@
package com.fsck.k9.ui.messagedetails
import android.view.View
import android.widget.TextView
import com.fsck.k9.ui.R
import com.mikepenz.fastadapter.FastAdapter
import com.mikepenz.fastadapter.items.AbstractItem
internal class MessageDateItem(private val date: String) : AbstractItem<MessageDateItem.ViewHolder>() {
override val type: Int = R.id.message_details_date
override val layoutRes = R.layout.message_details_date_item
override fun getViewHolder(v: View) = ViewHolder(v)
class ViewHolder(view: View) : FastAdapter.ViewHolder<MessageDateItem>(view) {
private val textView = view.findViewById<TextView>(R.id.date)
override fun bindView(item: MessageDateItem, payloads: List<Any>) {
textView.text = item.date
}
override fun unbindView(item: MessageDateItem) {
textView.text = null
}
}
}

View file

@ -0,0 +1,19 @@
package com.fsck.k9.ui.messagedetails
import android.view.View
import com.fsck.k9.ui.R
import com.mikepenz.fastadapter.FastAdapter
import com.mikepenz.fastadapter.items.AbstractItem
internal class MessageDetailsDividerItem : AbstractItem<MessageDetailsDividerItem.ViewHolder>() {
override val type: Int = R.id.message_details_divider
override val layoutRes = R.layout.message_details_divider_item
override fun getViewHolder(v: View) = ViewHolder(v)
class ViewHolder(view: View) : FastAdapter.ViewHolder<MessageDetailsDividerItem>(view) {
override fun bindView(item: MessageDetailsDividerItem, payloads: List<Any>) = Unit
override fun unbindView(item: MessageDetailsDividerItem) = Unit
}
}

View file

@ -0,0 +1,249 @@
package com.fsck.k9.ui.messagedetails
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.ProgressBar
import androidx.annotation.StringRes
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
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.ui.R
import com.fsck.k9.ui.observe
import com.fsck.k9.ui.withArguments
import com.mikepenz.fastadapter.FastAdapter
import com.mikepenz.fastadapter.GenericItem
import com.mikepenz.fastadapter.adapters.ItemAdapter
import com.mikepenz.fastadapter.listeners.ClickEventHook
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
class MessageDetailsFragment : ToolbarBottomSheetDialogFragment() {
private val viewModel: MessageDetailsViewModel by viewModel()
private val addToContactsLauncher: AddToContactsLauncher by inject()
private val showContactLauncher: ShowContactLauncher by inject()
private val contactPictureLoader: ContactPictureLoader by inject()
private lateinit var messageReference: MessageReference
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
messageReference = MessageReference.parse(arguments?.getString(ARG_REFERENCE))
?: error("Missing argument $ARG_REFERENCE")
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.message_bottom_sheet, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val dialog = checkNotNull(dialog)
dialog.isDismissWithAnimation = true
val toolbar = checkNotNull(toolbar)
toolbar.apply {
title = getString(R.string.message_details_toolbar_title)
navigationIcon = ContextCompat.getDrawable(requireContext(), R.drawable.ic_close)
setNavigationOnClickListener {
dismiss()
}
}
val progressBar = view.findViewById<ProgressBar>(R.id.message_details_progress)
val errorView = view.findViewById<View>(R.id.message_details_error)
val recyclerView = view.findViewById<RecyclerView>(R.id.message_details_list)
viewModel.loadData(messageReference).observe(this) { state ->
when (state) {
MessageDetailsState.Loading -> {
progressBar.isVisible = true
errorView.isVisible = false
recyclerView.isVisible = false
}
MessageDetailsState.Error -> {
progressBar.isVisible = false
errorView.isVisible = true
recyclerView.isVisible = false
}
is MessageDetailsState.DataLoaded -> {
progressBar.isVisible = false
errorView.isVisible = false
recyclerView.isVisible = true
setMessageDetails(recyclerView, state.details, state.showContactPicture)
}
}
}
}
private fun setMessageDetails(recyclerView: RecyclerView, details: MessageDetailsUi, showContactPicture: Boolean) {
val itemAdapter = ItemAdapter<GenericItem>().apply {
add(MessageDateItem(details.date ?: getString(R.string.message_details_missing_date)))
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)
add(MessageDetailsDividerItem())
addParticipants(details.to, R.string.message_details_to_section_title, showContactPicture)
addParticipants(details.cc, R.string.message_details_cc_section_title, showContactPicture)
addParticipants(details.bcc, R.string.message_details_bcc_section_title, showContactPicture)
}
val adapter = FastAdapter.with(itemAdapter).apply {
addEventHook(participantClickEventHook)
addEventHook(addToContactsClickEventHook)
addEventHook(composeClickEventHook)
addEventHook(overflowClickEventHook)
}
recyclerView.adapter = adapter
}
private fun ItemAdapter<GenericItem>.addParticipants(
participants: List<Participant>,
@StringRes title: Int,
showContactPicture: Boolean
) {
if (participants.isNotEmpty()) {
val extraText = if (participants.size > 1) participants.size.toString() else null
add(SectionHeaderItem(title = getString(title), extra = extraText))
for (participant in participants) {
add(ParticipantItem(contactPictureLoader, showContactPicture, participant))
}
}
}
private val participantClickEventHook = object : ClickEventHook<ParticipantItem>() {
override fun onBind(viewHolder: RecyclerView.ViewHolder): View? {
return if (viewHolder is ParticipantItem.ViewHolder) {
viewHolder.itemView
} else {
null
}
}
override fun onClick(v: View, position: Int, fastAdapter: FastAdapter<ParticipantItem>, item: ParticipantItem) {
val contactLookupUri = item.participant.contactLookupUri ?: return
showContact(contactLookupUri)
}
}
private fun showContact(contactLookupUri: Uri) {
showContactLauncher.launch(requireContext(), contactLookupUri)
}
private val addToContactsClickEventHook = object : ClickEventHook<ParticipantItem>() {
override fun onBind(viewHolder: RecyclerView.ViewHolder): View? {
return if (viewHolder is ParticipantItem.ViewHolder) {
viewHolder.menuAddContact
} else {
null
}
}
override fun onClick(v: View, position: Int, fastAdapter: FastAdapter<ParticipantItem>, item: ParticipantItem) {
val address = item.participant.address
addToContacts(address)
}
}
private fun addToContacts(address: Address) {
addToContactsLauncher.launch(context = requireContext(), name = address.personal, email = address.address)
}
private val composeClickEventHook = object : ClickEventHook<ParticipantItem>() {
override fun onBind(viewHolder: RecyclerView.ViewHolder): View? {
return if (viewHolder is ParticipantItem.ViewHolder) {
viewHolder.menuCompose
} else {
null
}
}
override fun onClick(v: View, position: Int, fastAdapter: FastAdapter<ParticipantItem>, item: ParticipantItem) {
val address = item.participant.address
composeMessageToAddress(address)
}
}
private fun composeMessageToAddress(address: Address) {
// TODO: Use the identity this message was sent to as sender identity
val intent = Intent(context, MessageCompose::class.java).apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_EMAIL, arrayOf(address.toString()))
putExtra(MessageCompose.EXTRA_ACCOUNT, messageReference.accountUuid)
}
dismiss()
requireContext().startActivity(intent)
}
private val overflowClickEventHook = object : ClickEventHook<ParticipantItem>() {
override fun onBind(viewHolder: RecyclerView.ViewHolder): View? {
return if (viewHolder is ParticipantItem.ViewHolder) {
viewHolder.menuOverflow
} else {
null
}
}
override fun onClick(v: View, position: Int, fastAdapter: FastAdapter<ParticipantItem>, item: ParticipantItem) {
showOverflowMenu(v, item.participant)
}
}
private fun showOverflowMenu(view: View, participant: Participant) {
val popupMenu = PopupMenu(requireContext(), view).apply {
inflate(R.menu.participant_overflow_menu)
}
if (participant.address.personal == null) {
popupMenu.menu.findItem(R.id.copy_name_and_email_address).isVisible = false
}
popupMenu.setOnMenuItemClickListener { item: MenuItem ->
onOverflowMenuItemClick(item.itemId, participant)
true
}
popupMenu.show()
}
private fun onOverflowMenuItemClick(itemId: Int, participant: Participant) {
when (itemId) {
R.id.copy_email_address -> viewModel.onCopyEmailAddressToClipboard(participant)
R.id.copy_name_and_email_address -> viewModel.onCopyNameAndEmailAddressToClipboard(participant)
}
}
companion object {
private const val ARG_REFERENCE = "reference"
fun create(messageReference: MessageReference): MessageDetailsFragment {
return MessageDetailsFragment().withArguments(
ARG_REFERENCE to messageReference.toIdentityString()
)
}
}
}

View file

@ -0,0 +1,22 @@
package com.fsck.k9.ui.messagedetails
import android.net.Uri
import com.fsck.k9.mail.Address
data class MessageDetailsUi(
val date: String?,
val from: List<Participant>,
val sender: List<Participant>,
val replyTo: List<Participant>,
val to: List<Participant>,
val cc: List<Participant>,
val bcc: List<Participant>
)
data class Participant(
val address: Address,
val contactLookupUri: Uri?
) {
val isInContacts: Boolean
get() = contactLookupUri != null
}

View file

@ -0,0 +1,95 @@
package com.fsck.k9.ui.messagedetails
import android.content.res.Resources
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.MessageDate
import com.fsck.k9.mailstore.MessageRepository
import com.fsck.k9.ui.R
import java.text.DateFormat
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
internal class MessageDetailsViewModel(
private val resources: Resources,
private val messageRepository: MessageRepository,
private val contactSettingsProvider: ContactSettingsProvider,
private val contacts: Contacts,
private val clipboardManager: ClipboardManager
) : ViewModel() {
private val dateFormat = DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.MEDIUM, Locale.getDefault())
private val uiState = MutableStateFlow<MessageDetailsState>(MessageDetailsState.Loading)
fun loadData(messageReference: MessageReference): StateFlow<MessageDetailsState> {
viewModelScope.launch(Dispatchers.IO) {
uiState.value = try {
val messageDetails = messageRepository.getMessageDetails(messageReference)
val senderList = messageDetails.sender?.let { listOf(it) } ?: emptyList()
val messageDetailsUi = MessageDetailsUi(
date = buildDisplayDate(messageDetails.date),
from = messageDetails.from.toParticipants(),
sender = senderList.toParticipants(),
replyTo = messageDetails.replyTo.toParticipants(),
to = messageDetails.to.toParticipants(),
cc = messageDetails.cc.toParticipants(),
bcc = messageDetails.bcc.toParticipants()
)
MessageDetailsState.DataLoaded(
showContactPicture = contactSettingsProvider.isShowContactPicture,
details = messageDetailsUi
)
} catch (e: Exception) {
MessageDetailsState.Error
}
}
return uiState
}
private fun buildDisplayDate(messageDate: MessageDate): String? {
return when (messageDate) {
is MessageDate.InvalidDate -> messageDate.dateHeader
MessageDate.MissingDate -> null
is MessageDate.ValidDate -> dateFormat.format(messageDate.date)
}
}
private fun List<Address>.toParticipants(): List<Participant> {
return this.map { address ->
Participant(
address = address,
contactLookupUri = contacts.getContactUri(address.address)
)
}
}
fun onCopyEmailAddressToClipboard(participant: Participant) {
val label = resources.getString(R.string.clipboard_label_email_address)
val emailAddress = participant.address.address
clipboardManager.setText(label, emailAddress)
}
fun onCopyNameAndEmailAddressToClipboard(participant: Participant) {
val label = resources.getString(R.string.clipboard_label_name_and_email_address)
val nameAndEmailAddress = participant.address.toString()
clipboardManager.setText(label, nameAndEmailAddress)
}
}
sealed interface MessageDetailsState {
object Loading : MessageDetailsState
object Error : MessageDetailsState
data class DataLoaded(
val showContactPicture: Boolean,
val details: MessageDetailsUi
) : MessageDetailsState
}

View file

@ -0,0 +1,74 @@
package com.fsck.k9.ui.messagedetails
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.widget.TooltipCompat
import androidx.core.view.isVisible
import com.fsck.k9.contacts.ContactPictureLoader
import com.fsck.k9.ui.R
import com.mikepenz.fastadapter.FastAdapter
import com.mikepenz.fastadapter.items.AbstractItem
internal class ParticipantItem(
private val contactPictureLoader: ContactPictureLoader,
private val showContactsPicture: Boolean,
val participant: Participant
) : AbstractItem<ParticipantItem.ViewHolder>() {
override val type: Int = R.id.message_details_participant
override val layoutRes = R.layout.message_details_participant_item
override fun getViewHolder(v: View) = ViewHolder(v)
class ViewHolder(view: View) : FastAdapter.ViewHolder<ParticipantItem>(view) {
val menuAddContact: View = view.findViewById(R.id.menu_add_contact)
val menuCompose: View = view.findViewById(R.id.menu_compose)
val menuOverflow: View = view.findViewById(R.id.menu_overflow)
private val contactPicture: ImageView = view.findViewById(R.id.contact_picture)
private val name = view.findViewById<TextView>(R.id.name)
private val email = view.findViewById<TextView>(R.id.email)
private val originalBackground = view.background
init {
TooltipCompat.setTooltipText(menuAddContact, menuAddContact.contentDescription)
TooltipCompat.setTooltipText(menuCompose, menuCompose.contentDescription)
TooltipCompat.setTooltipText(menuOverflow, menuOverflow.contentDescription)
}
override fun bindView(item: ParticipantItem, payloads: List<Any>) {
val participant = item.participant
val address = participant.address
val participantName = address.personal
if (participantName != null) {
name.text = participantName
email.text = address.address
} else {
name.text = address.address
email.isVisible = false
}
menuAddContact.isVisible = !participant.isInContacts
if (item.showContactsPicture) {
item.contactPictureLoader.setContactPicture(contactPicture, address)
} else {
contactPicture.isVisible = false
}
if (!item.participant.isInContacts) {
itemView.isClickable = false
itemView.background = null
}
}
override fun unbindView(item: ParticipantItem) {
name.text = null
email.text = null
email.isVisible = true
contactPicture.isVisible = true
itemView.background = originalBackground
itemView.isClickable = true
}
}
}

View file

@ -0,0 +1,32 @@
package com.fsck.k9.ui.messagedetails
import android.view.View
import android.widget.TextView
import com.fsck.k9.ui.R
import com.mikepenz.fastadapter.FastAdapter
import com.mikepenz.fastadapter.items.AbstractItem
internal class SectionHeaderItem(
private val title: String,
private val extra: String?
) : AbstractItem<SectionHeaderItem.ViewHolder>() {
override val type: Int = R.id.message_details_section_header
override val layoutRes = R.layout.message_details_section_header_item
override fun getViewHolder(v: View) = ViewHolder(v)
class ViewHolder(view: View) : FastAdapter.ViewHolder<SectionHeaderItem>(view) {
private val textView = view.findViewById<TextView>(R.id.title)
private val extraTextView = view.findViewById<TextView>(R.id.extra)
override fun bindView(item: SectionHeaderItem, payloads: List<Any>) {
textView.text = item.title
extraTextView.text = item.extra
}
override fun unbindView(item: SectionHeaderItem) {
textView.text = null
extraTextView.text = null
}
}
}

View file

@ -0,0 +1,16 @@
package com.fsck.k9.ui.messagedetails
import android.content.Context
import android.content.Intent
import android.net.Uri
internal class ShowContactLauncher {
fun launch(context: Context, contactLookupUri: Uri) {
val intent = Intent(Intent.ACTION_VIEW).apply {
data = contactLookupUri
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NEW_DOCUMENT
}
context.startActivity(intent)
}
}

View file

@ -1,36 +0,0 @@
package com.fsck.k9.ui.messageview
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import app.k9mail.ui.utils.bottomsheet.ToolbarBottomSheetDialogFragment
import com.fsck.k9.ui.R
class MessageBottomSheet : ToolbarBottomSheetDialogFragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.message_bottom_sheet, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val dialog = checkNotNull(dialog)
dialog.isDismissWithAnimation = true
val toolbar = checkNotNull(toolbar)
toolbar.apply {
title = "Message details"
navigationIcon = ContextCompat.getDrawable(requireContext(), R.drawable.ic_close)
setNavigationOnClickListener {
dismiss()
}
}
}
}

View file

@ -45,6 +45,7 @@ import com.fsck.k9.ui.R
import com.fsck.k9.ui.base.Theme import com.fsck.k9.ui.base.Theme
import com.fsck.k9.ui.base.ThemeManager import com.fsck.k9.ui.base.ThemeManager
import com.fsck.k9.ui.choosefolder.ChooseFolderActivity import com.fsck.k9.ui.choosefolder.ChooseFolderActivity
import com.fsck.k9.ui.messagedetails.MessageDetailsFragment
import com.fsck.k9.ui.messagesource.MessageSourceActivity import com.fsck.k9.ui.messagesource.MessageSourceActivity
import com.fsck.k9.ui.messageview.CryptoInfoDialog.OnClickShowCryptoKeyListener import com.fsck.k9.ui.messageview.CryptoInfoDialog.OnClickShowCryptoKeyListener
import com.fsck.k9.ui.messageview.MessageCryptoPresenter.MessageCryptoMvpView import com.fsck.k9.ui.messageview.MessageCryptoPresenter.MessageCryptoMvpView
@ -382,8 +383,8 @@ class MessageViewFragment :
private val messageHeaderClickListener = object : MessageHeaderClickListener { private val messageHeaderClickListener = object : MessageHeaderClickListener {
override fun onParticipantsContainerClick() { override fun onParticipantsContainerClick() {
val messageBottomSheet = MessageBottomSheet() val messageDetailsFragment = MessageDetailsFragment.create(messageReference)
messageBottomSheet.show(childFragmentManager, "message_details") messageDetailsFragment.show(childFragmentManager, "message_details")
} }
override fun onMenuItemClick(itemId: Int) { override fun onMenuItemClick(itemId: Int) {

View file

@ -1,62 +1,36 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout 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_width="match_parent"
android:layout_height="match_parent"> android:layout_height="wrap_content">
<LinearLayout <ProgressBar
android:id="@+id/message_details_progress"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginVertical="32dp"
tools:visibility="gone" />
<TextView
android:id="@+id/message_details_error"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical"> android:layout_marginHorizontal="16dp"
android:layout_marginVertical="32dp"
android:text="@string/message_details_loading_error"
android:visibility="gone"
tools:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/message_details_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:overScrollMode="never"
android:visibility="gone"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:visibility="visible" />
<TextView </FrameLayout>
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginHorizontal="16dp"
android:textAppearance="?attr/textAppearanceBody2"
android:gravity="end"
android:text="Wednesday, 11 January 2023, 10:31 pm" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginHorizontal="16dp"
android:textAppearance="?attr/textAppearanceBody2"
android:textStyle="bold"
android:text="From" />
<include layout="@layout/participant_list_item"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginHorizontal="16dp"
android:textAppearance="?attr/textAppearanceBody2"
android:textStyle="bold"
android:text="To" />
<include layout="@layout/participant_list_item"/>
<include layout="@layout/participant_list_item"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginHorizontal="16dp"
android:textAppearance="?attr/textAppearanceBody2"
android:textStyle="bold"
android:text="CC" />
<include layout="@layout/participant_list_item"/>
<include layout="@layout/participant_list_item"/>
<include layout="@layout/participant_list_item"/>
<include layout="@layout/participant_list_item"/>
<include layout="@layout/participant_list_item"/>
<include layout="@layout/participant_list_item"/>
<include layout="@layout/participant_list_item"/>
<include layout="@layout/participant_list_item"/>
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/date"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="16dp"
android:focusable="false"
android:gravity="end"
android:textAppearance="?attr/textAppearanceBody2"
tools:text="Wednesday, 11 January 2023, 10:31 pm" />

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<View xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="1px"
android:layout_marginHorizontal="32dp"
android:layout_marginVertical="8dp"
android:background="?attr/messageDetailsDividerColor"
android:focusable="false" />

View file

@ -5,9 +5,7 @@
android:id="@+id/participants_container" android:id="@+id/participants_container"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:clickable="true" android:background="?attr/selectableItemBackground"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal"> android:orientation="horizontal">
@ -15,13 +13,11 @@
android:id="@+id/contact_picture" android:id="@+id/contact_picture"
android:layout_width="40dp" android:layout_width="40dp"
android:layout_height="40dp" android:layout_height="40dp"
android:layout_margin="16dp" android:layout_marginStart="16dp"
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:layout_marginBottom="16dp" android:src="@drawable/ic_contact_picture"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent" />
android:src="@drawable/ic_contact_picture" />
<TextView <TextView
android:id="@+id/name" android:id="@+id/name"
@ -31,29 +27,27 @@
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:ellipsize="end" android:ellipsize="end"
android:gravity="center_vertical" android:gravity="center_vertical"
android:maxLines="1"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1" android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:textColor="?android:attr/textColorPrimary" android:textColor="?android:attr/textColorPrimary"
app:layout_constrainedWidth="true" app:layout_constrainedWidth="true"
app:layout_constraintBottom_toTopOf="@+id/address" app:layout_constraintBottom_toTopOf="@+id/email"
app:layout_constraintEnd_toStartOf="@+id/menu_add_contact" app:layout_constraintEnd_toStartOf="@+id/menu_add_contact"
app:layout_constraintHorizontal_bias="0.0" app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintHorizontal_chainStyle="packed" app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toEndOf="@id/contact_picture" app:layout_constraintStart_toEndOf="@id/contact_picture"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" app:layout_constraintVertical_chainStyle="packed"
android:text="Alice" /> app:layout_goneMarginBottom="12dp"
tools:text="Alice" />
<TextView <TextView
android:id="@+id/address" android:id="@+id/email"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="4dp" android:layout_marginEnd="4dp"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
android:ellipsize="end" android:ellipsize="end"
android:gravity="center_vertical" android:gravity="center_vertical"
android:maxLines="1"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2" android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
android:textColor="?android:attr/textColorSecondary" android:textColor="?android:attr/textColorSecondary"
app:layout_constrainedWidth="true" app:layout_constrainedWidth="true"
@ -63,17 +57,17 @@
app:layout_constraintHorizontal_chainStyle="packed" app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="@+id/name" app:layout_constraintStart_toStartOf="@+id/name"
app:layout_constraintTop_toBottomOf="@+id/name" app:layout_constraintTop_toBottomOf="@+id/name"
android:text="alice@domain.example" /> tools:text="alice@domain.example" />
<ImageView <ImageView
android:id="@+id/menu_add_contact" android:id="@+id/menu_add_contact"
android:layout_width="48dp" android:layout_width="48dp"
android:layout_height="48dp" android:layout_height="72dp"
android:background="?attr/controlBackground" android:background="?attr/controlBackground"
android:paddingHorizontal="12dp"
android:clickable="true" android:clickable="true"
android:contentDescription="@string/action_add_to_contacts"
android:focusable="true" android:focusable="true"
android:paddingHorizontal="12dp"
app:layout_constraintEnd_toStartOf="@+id/menu_compose" app:layout_constraintEnd_toStartOf="@+id/menu_compose"
app:layout_constraintTop_toTopOf="@+id/menu_compose" app:layout_constraintTop_toTopOf="@+id/menu_compose"
app:srcCompat="?attr/messageDetailsAddContactIcon" /> app:srcCompat="?attr/messageDetailsAddContactIcon" />
@ -81,29 +75,30 @@
<ImageView <ImageView
android:id="@+id/menu_compose" android:id="@+id/menu_compose"
android:layout_width="48dp" android:layout_width="48dp"
android:layout_height="48dp" android:layout_height="72dp"
android:background="?attr/controlBackground" android:background="?attr/controlBackground"
android:clickable="true" android:clickable="true"
android:contentDescription="@string/compose_action"
android:focusable="true" android:focusable="true"
android:paddingHorizontal="12dp"
app:layout_constraintEnd_toStartOf="@id/menu_overflow" app:layout_constraintEnd_toStartOf="@id/menu_overflow"
app:layout_constraintTop_toTopOf="@+id/menu_overflow" app:layout_constraintTop_toTopOf="@+id/menu_overflow"
android:paddingHorizontal="12dp"
app:srcCompat="@drawable/ic_envelope" /> app:srcCompat="@drawable/ic_envelope" />
<ImageView <ImageView
android:id="@+id/menu_overflow" android:id="@+id/menu_overflow"
android:layout_width="40dp" android:layout_width="40dp"
android:layout_height="48dp" android:layout_height="72dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:background="?attr/controlBackground" android:background="?attr/controlBackground"
android:clickable="true" android:clickable="true"
android:contentDescription="@string/abc_action_menu_overflow_description"
android:focusable="true" android:focusable="true"
android:paddingStart="6dp" android:paddingStart="6dp"
android:paddingEnd="10dp" android:paddingEnd="10dp"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0"
app:srcCompat="@drawable/dots_vertical" /> app:srcCompat="@drawable/dots_vertical" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:focusable="false"
android:orientation="horizontal">
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:textAppearance="?attr/textAppearanceOverline"
tools:text="To" />
<TextView
android:id="@+id/extra"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="16dp"
android:textAppearance="?attr/textAppearanceOverline"
android:textColor="?android:attr/textColorSecondary"
tools:text="3" />
</LinearLayout>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/copy_email_address"
android:title="@string/action_copy_email_address" />
<item
android:id="@+id/copy_name_and_email_address"
android:title="@string/action_copy_name_and_email_address" />
</menu>

View file

@ -110,6 +110,7 @@
<attr name="messageStarColor" format="color"/> <attr name="messageStarColor" format="color"/>
<attr name="messageViewBackgroundColor" format="reference|color"/> <attr name="messageViewBackgroundColor" format="reference|color"/>
<attr name="messageDetailsAddContactIcon" format="reference"/> <attr name="messageDetailsAddContactIcon" format="reference"/>
<attr name="messageDetailsDividerColor" format="reference|color"/>
<attr name="attachmentCardBackground" format="reference|color"/> <attr name="attachmentCardBackground" format="reference|color"/>
<attr name="composerBackgroundColor" format="color"/> <attr name="composerBackgroundColor" format="color"/>
<attr name="contactPictureFallbackDefaultBackgroundColor" format="reference|color"/> <attr name="contactPictureFallbackDefaultBackgroundColor" format="reference|color"/>

View file

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

View file

@ -1307,4 +1307,36 @@ You can keep this message and use it as a backup for your secret key. If you wan
<string name="message_list_error_title">Error</string> <string name="message_list_error_title">Error</string>
<!-- Displayed instead of the message list when a folder couldn't be found, e.g. due to an outdated home screen shortcut. --> <!-- Displayed instead of the message list when a folder couldn't be found, e.g. due to an outdated home screen shortcut. -->
<string name="message_list_error_folder_not_found">Folder not found</string> <string name="message_list_error_folder_not_found">Folder not found</string>
<!-- Title of the toolbar that is displayed when the message details bottom sheet has been expanded -->
<string name="message_details_toolbar_title">Message details</string>
<!-- Displayed when an email is missing the 'Date' header field and no date can be displayed in the message details bottom sheet. -->
<string name="message_details_missing_date">Missing \'Date\' header</string>
<!-- Title of the "From" section in the message details bottom sheet. This should probably be the same as the string 'recipient_from'. -->
<string name="message_details_from_section_title">From</string>
<!-- Title of the "Sender" section in the message details bottom sheet. This refers to the 'Sender' header. Be careful with translations. -->
<string name="message_details_sender_section_title">Sender</string>
<!-- Title of the "Reply to" section in the message details bottom sheet. This should probably be the same as the string 'reply_to_label'. -->
<string name="message_details_replyto_section_title">Reply to</string>
<!-- Title of the "To" section in the message details bottom sheet. This should probably be the same as the string 'recipient_to'. -->
<string name="message_details_to_section_title">To</string>
<!-- Title of the "Cc" section in the message details bottom sheet. This should probably be the same as the string 'recipient_cc'. -->
<string name="message_details_cc_section_title">Cc</string>
<!-- Title of the "Bcc" section in the message details bottom sheet. This should probably be the same as the string 'recipient_bcc'. -->
<string name="message_details_bcc_section_title">Bcc</string>
<!-- Displayed when loading the message details has failed -->
<string name="message_details_loading_error">An error occurred while loading the message details.</string>
<!-- Name of the action to add a person to contacts. Usually displayed in a menu or in a tooltip when long-pressing the associated icon. -->
<string name="action_add_to_contacts">Add to contacts</string>
<!-- Name of the action to copy an email address to the clipboard. Usually displayed in a menu or in a tooltip when long-pressing the associated icon. -->
<string name="action_copy_email_address">Copy email address</string>
<!-- Name of the action to copy a name and email address to the clipboard. Usually displayed in a menu or in a tooltip when long-pressing the associated icon. -->
<string name="action_copy_name_and_email_address">Copy name and email address</string>
<!-- A user visible label for the email address copied to the clipboard -->
<string name="clipboard_label_email_address">Email address</string>
<!-- A user visible label for the name and email address copied to the clipboard -->
<string name="clipboard_label_name_and_email_address">Name and email address</string>
</resources> </resources>

View file

@ -134,6 +134,7 @@
<item name="messageStarColor">#fbbc04</item> <item name="messageStarColor">#fbbc04</item>
<item name="messageViewBackgroundColor">#ffffffff</item> <item name="messageViewBackgroundColor">#ffffffff</item>
<item name="messageDetailsAddContactIcon">@drawable/ic_person_add</item> <item name="messageDetailsAddContactIcon">@drawable/ic_person_add</item>
<item name="messageDetailsDividerColor">#ffcccccc</item>
<item name="attachmentCardBackground">#e8e8e8</item> <item name="attachmentCardBackground">#e8e8e8</item>
<item name="contactPictureFallbackDefaultBackgroundColor">#ffababab</item> <item name="contactPictureFallbackDefaultBackgroundColor">#ffababab</item>
<item name="contactPictureFallbackBackgroundColors">@array/contact_picture_fallback_background_colors_light</item> <item name="contactPictureFallbackBackgroundColors">@array/contact_picture_fallback_background_colors_light</item>
@ -296,6 +297,7 @@
<item name="messageStarColor">#fdd663</item> <item name="messageStarColor">#fdd663</item>
<item name="messageViewBackgroundColor">#000000</item> <item name="messageViewBackgroundColor">#000000</item>
<item name="messageDetailsAddContactIcon">@drawable/ic_person_add</item> <item name="messageDetailsAddContactIcon">@drawable/ic_person_add</item>
<item name="messageDetailsDividerColor">#ff555555</item>
<item name="attachmentCardBackground">#313131</item> <item name="attachmentCardBackground">#313131</item>
<item name="contactTokenBackgroundColor">#313131</item> <item name="contactTokenBackgroundColor">#313131</item>
<item name="contactPictureFallbackDefaultBackgroundColor">#ff606060</item> <item name="contactPictureFallbackDefaultBackgroundColor">#ff606060</item>

View file

@ -2,7 +2,7 @@
"inbox": { "inbox": {
"name": "Inbox", "name": "Inbox",
"type": "INBOX", "type": "INBOX",
"messageServerIds": ["intro"] "messageServerIds": ["intro", "many_recipients"]
}, },
"trash": { "trash": {
"name": "Trash", "name": "Trash",

View file

@ -0,0 +1,42 @@
MIME-Version: 1.0
From: "Alice" <from1@k9mail.example>, "Bob" <from2@k9mail.example>
Sender: "Bernd" <sender@k9mail.example>
Reply-To: <reply-to@k9mail.example>
Date: Mon, 23 Jan 2023 12:00:00 +0100
Message-ID: <inbox-2@k9mail.example>
Subject: Message details demo
To: "User 1" <to1@k9mail.example>,
"User 2" <to2@k9mail.example>,
"User 3" <to3@k9mail.example>,
"User 4" <to4@k9mail.example>,
"User 5" <to5@k9mail.example>,
"User 6" <to6@k9mail.example>,
"User 7" <to7@k9mail.example>,
"User 8" <to8@k9mail.example>,
"User 9" <to9@k9mail.example>,
"User 10" <to10@k9mail.example>,
"User 11" <to11@k9mail.example>,
"User 12" <to12@k9mail.example>,
"User 13" <to13@k9mail.example>,
"User 14" <to14@k9mail.example>,
"User 15" <to15@k9mail.example>,
"User 16" <to16@k9mail.example>,
"User 17" <to17@k9mail.example>,
"User 18" <to18@k10mail.example>,
"User 19" <to19@k11mail.example>,
"User 20" <to20@k12mail.example>
Cc: "Copy 1" <cc1@k9mail.example>,
"Copy 2" <cc2@k9mail.example>,
"Copy 3" <cc3@k9mail.example>
Bcc: "Blind 1" <bcc1@k9mail.example>,
"Blind 2" <bcc2@k9mail.example>,
"Blind 3" <bcc3@k9mail.example>
Content-Type: text/plain; charset=UTF-8
This message contains…
- multiple addresses in the From: header
- a Sender: header
- a Reply-To: header
- multiple addresses in the To: header
- multiple addresses in the Cc: header
- multiple addresses in the Bcc: header

View file

@ -34,7 +34,6 @@ import androidx.annotation.StyleRes
import androidx.appcompat.app.AppCompatDialog import androidx.appcompat.app.AppCompatDialog
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.graphics.Insets
import androidx.core.view.AccessibilityDelegateCompat import androidx.core.view.AccessibilityDelegateCompat
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
@ -191,6 +190,19 @@ class ToolbarBottomSheetDialog internal constructor(context: Context, @StyleRes
container.doOnLayout { container.doOnLayout {
behavior.peekHeight = container.height / 2 behavior.peekHeight = container.height / 2
} }
bottomSheet.doOnLayout {
// Don't draw the toolbar underneath the status bar if the bottom sheet doesn't cover the whole screen
// anyway.
if (bottomSheet.width < container.width) {
container.fitsSystemWindows = true
coordinator?.fitsSystemWindows = true
setToolbarVisibilityCallback(topInset = 0)
} else {
container.fitsSystemWindows = false
coordinator?.fitsSystemWindows = false
}
}
} }
return container return container
@ -200,27 +212,15 @@ class ToolbarBottomSheetDialog internal constructor(context: Context, @StyleRes
private fun wrapInBottomSheet(view: View): View { private fun wrapInBottomSheet(view: View): View {
ensureContainerAndBehavior() ensureContainerAndBehavior()
val window = checkNotNull(window)
val container = checkNotNull(container) val container = checkNotNull(container)
val coordinator = checkNotNull(coordinator) val coordinator = checkNotNull(coordinator)
val bottomSheet = checkNotNull(bottomSheet) val bottomSheet = checkNotNull(bottomSheet)
val toolbar = checkNotNull(toolbar)
ViewCompat.setOnApplyWindowInsetsListener(bottomSheet) { _, windowInsets -> ViewCompat.setOnApplyWindowInsetsListener(bottomSheet) { _, windowInsets ->
val behavior = checkNotNull(internalBehavior)
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
val topInset = insets.top
toolbar.setPadding(0, insets.top, 0, 0) setToolbarVisibilityCallback(topInset)
toolbarVisibilityCallback?.let { oldCallback ->
behavior.removeBottomSheetCallback(oldCallback)
}
val windowInsetsController = WindowCompat.getInsetsController(window, bottomSheet)
val newCallback = ToolbarVisibilityCallback(windowInsetsController, toolbar, insets, behavior)
behavior.addBottomSheetCallback(newCallback)
this.toolbarVisibilityCallback = newCallback
WindowInsetsCompat.CONSUMED WindowInsetsCompat.CONSUMED
} }
@ -268,6 +268,23 @@ class ToolbarBottomSheetDialog internal constructor(context: Context, @StyleRes
return container return container
} }
private fun setToolbarVisibilityCallback(topInset: Int) {
val window = checkNotNull(window)
val bottomSheet = checkNotNull(bottomSheet)
val toolbar = checkNotNull(toolbar)
val behavior = checkNotNull(internalBehavior)
toolbarVisibilityCallback?.let { oldCallback ->
behavior.removeBottomSheetCallback(oldCallback)
}
val windowInsetsController = WindowCompat.getInsetsController(window, bottomSheet)
val newCallback = ToolbarVisibilityCallback(windowInsetsController, toolbar, topInset, behavior)
behavior.addBottomSheetCallback(newCallback)
this.toolbarVisibilityCallback = newCallback
}
fun removeDefaultCallback() { fun removeDefaultCallback() {
checkNotNull(internalBehavior).removeBottomSheetCallback(cancelDialogCallback) checkNotNull(internalBehavior).removeBottomSheetCallback(cancelDialogCallback)
} }
@ -285,7 +302,7 @@ class ToolbarBottomSheetDialog internal constructor(context: Context, @StyleRes
private class ToolbarVisibilityCallback( private class ToolbarVisibilityCallback(
private val windowInsetsController: WindowInsetsControllerCompat, private val windowInsetsController: WindowInsetsControllerCompat,
private val toolbar: Toolbar, private val toolbar: Toolbar,
private val insets: Insets, private val topInset: Int,
private val behavior: BottomSheetBehavior<FrameLayout> private val behavior: BottomSheetBehavior<FrameLayout>
) : LayoutAwareBottomSheetCallback() { ) : LayoutAwareBottomSheetCallback() {
@ -335,14 +352,14 @@ class ToolbarBottomSheetDialog internal constructor(context: Context, @StyleRes
val top = bottomSheet.top val top = bottomSheet.top
val collapsedOffset = (bottomSheet.parent as View).height / 2 val collapsedOffset = (bottomSheet.parent as View).height / 2
if (top >= collapsedOffset) { if (top >= collapsedOffset || behavior.expandedOffset > 0) {
toolbar.isInvisible = true toolbar.isInvisible = true
bottomSheet.setPadding(0, 0, 0, 0) bottomSheet.setPadding(0, 0, 0, 0)
return return
} }
val toolbarHeight = toolbar.height - toolbar.paddingTop val toolbarHeight = toolbar.height - toolbar.paddingTop
val toolbarHeightAndInset = toolbarHeight + insets.top val toolbarHeightAndInset = toolbarHeight + topInset
val expandedPercentage = val expandedPercentage =
((collapsedOffset - top).toFloat() / (collapsedOffset - behavior.expandedOffset)).coerceAtMost(1f) ((collapsedOffset - top).toFloat() / (collapsedOffset - behavior.expandedOffset)).coerceAtMost(1f)
@ -350,7 +367,12 @@ class ToolbarBottomSheetDialog internal constructor(context: Context, @StyleRes
val paddingTop = (toolbarHeightAndInset * expandedPercentage).toInt().coerceAtLeast(0) val paddingTop = (toolbarHeightAndInset * expandedPercentage).toInt().coerceAtLeast(0)
bottomSheet.setPadding(0, paddingTop, 0, 0) bottomSheet.setPadding(0, paddingTop, 0, 0)
if (paddingTop > toolbarHeight) { // Start showing the toolbar when the bottom sheet is a toolbar height away from the top of the screen.
// This value was chosen rather arbitrarily because it looked nice enough.
val toolbarPercentage =
((toolbarHeight - top).toFloat() / (toolbarHeight - behavior.expandedOffset)).coerceAtLeast(0f)
if (toolbarPercentage > 0) {
toolbar.isVisible = true toolbar.isVisible = true
// Set the toolbar's top padding so the toolbar covers the bottom sheet's whole top padding // Set the toolbar's top padding so the toolbar covers the bottom sheet's whole top padding
@ -360,18 +382,14 @@ class ToolbarBottomSheetDialog internal constructor(context: Context, @StyleRes
// Translate the toolbar view so it is drawn on top of the bottom sheet's top padding // Translate the toolbar view so it is drawn on top of the bottom sheet's top padding
toolbar.translationY = -paddingTop.toFloat() toolbar.translationY = -paddingTop.toFloat()
// Start fading in the toolbar when the bottom sheet is a toolbar height away from the top of the screen. toolbar.alpha = toolbarPercentage
// This value was chosen rather arbitrarily because it looked nice.
val alphaPercentage =
((toolbarHeight - top).toFloat() / (toolbarHeight - behavior.expandedOffset)).coerceAtLeast(0f)
toolbar.alpha = alphaPercentage
} else { } else {
toolbar.isInvisible = true toolbar.isInvisible = true
} }
} }
private fun setStatusBarColor(bottomSheet: View) { private fun setStatusBarColor(bottomSheet: View) {
val toolbarLightThreshold = insets.top / 2 val toolbarLightThreshold = topInset / 2
if (bottomSheet.top < toolbarLightThreshold) { if (bottomSheet.top < toolbarLightThreshold) {
windowInsetsController.isAppearanceLightStatusBars = lightToolbar windowInsetsController.isAppearanceLightStatusBars = lightToolbar
} else if (bottomSheet.top != 0) { } else if (bottomSheet.top != 0) {