diff --git a/app/core/build.gradle b/app/core/build.gradle
index 9c8ca3079..46bba0484 100644
--- a/app/core/build.gradle
+++ b/app/core/build.gradle
@@ -25,6 +25,7 @@ dependencies {
implementation libs.moshi
implementation libs.timber
implementation libs.mime4j.core
+ implementation libs.mime4j.dom
testImplementation project(':mail:testing')
testImplementation project(":backend:imap")
diff --git a/app/core/src/main/java/com/fsck/k9/helper/Contacts.java b/app/core/src/main/java/com/fsck/k9/helper/Contacts.java
index 2043e6bd5..d2642f7d0 100644
--- a/app/core/src/main/java/com/fsck/k9/helper/Contacts.java
+++ b/app/core/src/main/java/com/fsck/k9/helper/Contacts.java
@@ -1,6 +1,8 @@
package com.fsck.k9.helper;
+import java.util.HashMap;
+
import android.Manifest;
import android.content.ContentResolver;
import android.content.Context;
@@ -9,13 +11,12 @@ import android.content.pm.PackageManager;
import android.database.Cursor;
import android.net.Uri;
import android.provider.ContactsContract;
-import timber.log.Timber;
import android.provider.ContactsContract.CommonDataKinds.Photo;
+
+import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
-
import com.fsck.k9.mail.Address;
-
-import java.util.HashMap;
+import timber.log.Timber;
/**
* Helper class to access the contacts stored on the device.
@@ -37,7 +38,8 @@ public class Contacts {
ContactsContract.CommonDataKinds.Email._ID,
ContactsContract.Contacts.DISPLAY_NAME,
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 LOOKUP_KEY_INDEX = 4;
+
/**
* Get instance of the Contacts class.
@@ -169,6 +173,24 @@ public class Contacts {
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.
*
diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/MessageDetails.kt b/app/core/src/main/java/com/fsck/k9/mailstore/MessageDetails.kt
new file mode 100644
index 000000000..19c14855f
--- /dev/null
+++ b/app/core/src/main/java/com/fsck/k9/mailstore/MessageDetails.kt
@@ -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
,
+ val sender: Address?,
+ val replyTo: List,
+ val to: List,
+ val cc: List,
+ val bcc: List
+)
+
+sealed interface MessageDate {
+ data class ValidDate(val date: Date) : MessageDate
+
+ data class InvalidDate(val dateHeader: String) : MessageDate
+
+ object MissingDate : MessageDate
+}
diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/MessageRepository.kt b/app/core/src/main/java/com/fsck/k9/mailstore/MessageRepository.kt
index aa88bc1e1..e2fc950b1 100644
--- a/app/core/src/main/java/com/fsck/k9/mailstore/MessageRepository.kt
+++ b/app/core/src/main/java/com/fsck/k9/mailstore/MessageRepository.kt
@@ -1,11 +1,69 @@
package com.fsck.k9.mailstore
import com.fsck.k9.controller.MessageReference
+import com.fsck.k9.mail.Address
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) {
fun getHeaders(messageReference: MessageReference): List {
val messageStore = messageStoreManager.getMessageStore(messageReference.accountUuid)
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.firstHeaderOrNull(name: String): String? {
+ return firstOrNull { it.name.equals(name, ignoreCase = true) }?.value
+ }
+
+ private fun List.parseAddresses(headerName: String): List {
+ return Address.parse(MimeUtility.unfold(firstHeaderOrNull(headerName))).toList()
+ }
+
+ private fun List.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",
+ )
+ }
}
diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/MessageStore.kt b/app/core/src/main/java/com/fsck/k9/mailstore/MessageStore.kt
index 75c292e23..b7b5e88d6 100644
--- a/app/core/src/main/java/com/fsck/k9/mailstore/MessageStore.kt
+++ b/app/core/src/main/java/com/fsck/k9/mailstore/MessageStore.kt
@@ -155,6 +155,11 @@ interface MessageStore {
*/
fun getHeaders(folderId: Long, messageServerId: String): List
+ /**
+ * Retrieve selected header fields of a message.
+ */
+ fun getHeaders(folderId: Long, messageServerId: String, headerNames: Set): List
+
/**
* Return the size of this message store in bytes.
*/
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/K9MessageStore.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/K9MessageStore.kt
index ca1e7dec6..aeecdc035 100644
--- a/app/storage/src/main/java/com/fsck/k9/storage/messages/K9MessageStore.kt
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/K9MessageStore.kt
@@ -132,6 +132,10 @@ class K9MessageStore(
return retrieveMessageOperations.getHeaders(folderId, messageServerId)
}
+ override fun getHeaders(folderId: Long, messageServerId: String, headerNames: Set): List {
+ return retrieveMessageOperations.getHeaders(folderId, messageServerId, headerNames)
+ }
+
override fun destroyMessages(folderId: Long, messageServerIds: Collection) {
deleteMessageOperations.destroyMessages(folderId, messageServerIds)
}
diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageOperations.kt
index c094ae3b3..041dda747 100644
--- a/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageOperations.kt
+++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageOperations.kt
@@ -2,6 +2,7 @@ package com.fsck.k9.storage.messages
import androidx.core.database.getLongOrNull
import com.fsck.k9.K9
+import com.fsck.k9.helper.mapToSet
import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.Header
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 {
+ fun getHeaders(folderId: Long, messageServerId: String, headerNames: Set? = null): List {
return lockableDatabase.execute(false) { database ->
database.rawQuery(
"SELECT message_parts.header FROM messages" +
@@ -178,10 +179,13 @@ internal class RetrieveMessageOperations(private val lockableDatabase: LockableD
if (!cursor.moveToFirst()) throw MessageNotFoundException(folderId, messageServerId)
val headerBytes = cursor.getBlob(0)
+ val lowercaseHeaderNames = headerNames?.mapToSet(headerNames.size) { it.lowercase() }
val header = MimeHeader()
MessageHeaderParser.parse(headerBytes.inputStream()) { name, value ->
- header.addRawHeader(name, value)
+ if (lowercaseHeaderNames == null || name.lowercase() in lowercaseHeaderNames) {
+ header.addRawHeader(name, value)
+ }
}
header.headers
diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageOperationsTest.kt
index 09dccb31d..09246c3cb 100644
--- a/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageOperationsTest.kt
+++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageOperationsTest.kt
@@ -170,6 +170,34 @@ class RetrieveMessageOperationsTest : RobolectricTest() {
)
}
+ @Test
+ fun `get some headers`() {
+ val messagePartId = sqliteDatabase.createMessagePart(
+ header = """
+ From:
+ To: Bob
+ 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", ""),
+ Header("To", "Bob "),
+ Header("Message-Id", "<20210401012345.123456789A@domain.example>")
+ )
+ )
+ }
+
@Test
fun `get oldest message date`() {
sqliteDatabase.createMessage(folderId = 1, date = 42)
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/UiKoinModules.kt b/app/ui/legacy/src/main/java/com/fsck/k9/UiKoinModules.kt
index 79f928ec3..9ae3e125b 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/UiKoinModules.kt
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/UiKoinModules.kt
@@ -11,6 +11,7 @@ import com.fsck.k9.ui.choosefolder.chooseFolderUiModule
import com.fsck.k9.ui.endtoend.endToEndUiModule
import com.fsck.k9.ui.folders.foldersUiModule
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.messagesource.messageSourceModule
import com.fsck.k9.ui.settings.settingsUiModule
@@ -33,5 +34,6 @@ val uiModules = listOf(
viewModule,
changelogUiModule,
messageSourceModule,
- accountUiModule
+ accountUiModule,
+ messageDetailsUiModule
)
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/AddToContactsLauncher.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/AddToContactsLauncher.kt
new file mode 100644
index 000000000..399883c40
--- /dev/null
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/AddToContactsLauncher.kt
@@ -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)
+ }
+}
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/ContactSettingsProvider.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/ContactSettingsProvider.kt
new file mode 100644
index 000000000..eefb56e8e
--- /dev/null
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/ContactSettingsProvider.kt
@@ -0,0 +1,8 @@
+package com.fsck.k9.ui.messagedetails
+
+import com.fsck.k9.K9
+
+class ContactSettingsProvider {
+ val isShowContactPicture: Boolean
+ get() = K9.isShowContactPicture
+}
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/KoinModule.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/KoinModule.kt
new file mode 100644
index 000000000..75f6cd595
--- /dev/null
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/KoinModule.kt
@@ -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() }
+}
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDateItem.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDateItem.kt
new file mode 100644
index 000000000..0c5a52d7f
--- /dev/null
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDateItem.kt
@@ -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() {
+ 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(view) {
+ private val textView = view.findViewById(R.id.date)
+
+ override fun bindView(item: MessageDateItem, payloads: List) {
+ textView.text = item.date
+ }
+
+ override fun unbindView(item: MessageDateItem) {
+ textView.text = null
+ }
+ }
+}
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDetailsDividerItem.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDetailsDividerItem.kt
new file mode 100644
index 000000000..9706b2d4b
--- /dev/null
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDetailsDividerItem.kt
@@ -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() {
+ 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(view) {
+ override fun bindView(item: MessageDetailsDividerItem, payloads: List) = Unit
+
+ override fun unbindView(item: MessageDetailsDividerItem) = Unit
+ }
+}
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDetailsFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDetailsFragment.kt
new file mode 100644
index 000000000..5f877dc21
--- /dev/null
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDetailsFragment.kt
@@ -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(R.id.message_details_progress)
+ val errorView = view.findViewById(R.id.message_details_error)
+ val recyclerView = view.findViewById(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().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.addParticipants(
+ participants: List,
+ @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() {
+ 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, item: ParticipantItem) {
+ val contactLookupUri = item.participant.contactLookupUri ?: return
+ showContact(contactLookupUri)
+ }
+ }
+
+ private fun showContact(contactLookupUri: Uri) {
+ showContactLauncher.launch(requireContext(), contactLookupUri)
+ }
+
+ private val addToContactsClickEventHook = object : ClickEventHook() {
+ 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, 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() {
+ 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, 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() {
+ 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, 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()
+ )
+ }
+ }
+}
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDetailsUi.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDetailsUi.kt
new file mode 100644
index 000000000..e157e41a9
--- /dev/null
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDetailsUi.kt
@@ -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,
+ val sender: List,
+ val replyTo: List,
+ val to: List,
+ val cc: List,
+ val bcc: List
+)
+
+data class Participant(
+ val address: Address,
+ val contactLookupUri: Uri?
+) {
+ val isInContacts: Boolean
+ get() = contactLookupUri != null
+}
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDetailsViewModel.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDetailsViewModel.kt
new file mode 100644
index 000000000..e67ee44fc
--- /dev/null
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDetailsViewModel.kt
@@ -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.Loading)
+
+ fun loadData(messageReference: MessageReference): StateFlow {
+ 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.toParticipants(): List {
+ 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
+}
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/ParticipantItem.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/ParticipantItem.kt
new file mode 100644
index 000000000..51800bd8c
--- /dev/null
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/ParticipantItem.kt
@@ -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() {
+ 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(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(R.id.name)
+ private val email = view.findViewById(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) {
+ 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
+ }
+ }
+}
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/SectionHeaderItem.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/SectionHeaderItem.kt
new file mode 100644
index 000000000..487aba729
--- /dev/null
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/SectionHeaderItem.kt
@@ -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() {
+ 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(view) {
+ private val textView = view.findViewById(R.id.title)
+ private val extraTextView = view.findViewById(R.id.extra)
+
+ override fun bindView(item: SectionHeaderItem, payloads: List) {
+ textView.text = item.title
+ extraTextView.text = item.extra
+ }
+
+ override fun unbindView(item: SectionHeaderItem) {
+ textView.text = null
+ extraTextView.text = null
+ }
+ }
+}
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/ShowContactLauncher.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/ShowContactLauncher.kt
new file mode 100644
index 000000000..b6ef5d799
--- /dev/null
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/ShowContactLauncher.kt
@@ -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)
+ }
+}
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageBottomSheet.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageBottomSheet.kt
deleted file mode 100644
index a32bc6a53..000000000
--- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageBottomSheet.kt
+++ /dev/null
@@ -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()
- }
- }
- }
-}
diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt
index dbe510611..acb52cf40 100644
--- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt
+++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt
@@ -45,6 +45,7 @@ import com.fsck.k9.ui.R
import com.fsck.k9.ui.base.Theme
import com.fsck.k9.ui.base.ThemeManager
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.messageview.CryptoInfoDialog.OnClickShowCryptoKeyListener
import com.fsck.k9.ui.messageview.MessageCryptoPresenter.MessageCryptoMvpView
@@ -382,8 +383,8 @@ class MessageViewFragment :
private val messageHeaderClickListener = object : MessageHeaderClickListener {
override fun onParticipantsContainerClick() {
- val messageBottomSheet = MessageBottomSheet()
- messageBottomSheet.show(childFragmentManager, "message_details")
+ val messageDetailsFragment = MessageDetailsFragment.create(messageReference)
+ messageDetailsFragment.show(childFragmentManager, "message_details")
}
override fun onMenuItemClick(itemId: Int) {
diff --git a/app/ui/legacy/src/main/res/layout/message_bottom_sheet.xml b/app/ui/legacy/src/main/res/layout/message_bottom_sheet.xml
index df07b9a48..b97430c3e 100644
--- a/app/ui/legacy/src/main/res/layout/message_bottom_sheet.xml
+++ b/app/ui/legacy/src/main/res/layout/message_bottom_sheet.xml
@@ -1,62 +1,36 @@
-
+ android:layout_height="wrap_content">
-
+
+
+ android:layout_marginHorizontal="16dp"
+ android:layout_marginVertical="32dp"
+ android:text="@string/message_details_loading_error"
+ android:visibility="gone"
+ tools:visibility="gone" />
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
diff --git a/app/ui/legacy/src/main/res/layout/message_details_date_item.xml b/app/ui/legacy/src/main/res/layout/message_details_date_item.xml
new file mode 100644
index 000000000..2888a4294
--- /dev/null
+++ b/app/ui/legacy/src/main/res/layout/message_details_date_item.xml
@@ -0,0 +1,12 @@
+
+
diff --git a/app/ui/legacy/src/main/res/layout/message_details_divider_item.xml b/app/ui/legacy/src/main/res/layout/message_details_divider_item.xml
new file mode 100644
index 000000000..8a456ec9a
--- /dev/null
+++ b/app/ui/legacy/src/main/res/layout/message_details_divider_item.xml
@@ -0,0 +1,8 @@
+
+
diff --git a/app/ui/legacy/src/main/res/layout/participant_list_item.xml b/app/ui/legacy/src/main/res/layout/message_details_participant_item.xml
similarity index 82%
rename from app/ui/legacy/src/main/res/layout/participant_list_item.xml
rename to app/ui/legacy/src/main/res/layout/message_details_participant_item.xml
index b8cfc5034..ce99f941f 100644
--- a/app/ui/legacy/src/main/res/layout/participant_list_item.xml
+++ b/app/ui/legacy/src/main/res/layout/message_details_participant_item.xml
@@ -5,9 +5,7 @@
android:id="@+id/participants_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:clickable="true"
- android:focusable="true"
- android:foreground="?attr/selectableItemBackground"
+ android:background="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal">
@@ -15,13 +13,11 @@
android:id="@+id/contact_picture"
android:layout_width="40dp"
android:layout_height="40dp"
- android:layout_margin="16dp"
+ android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
- android:layout_marginBottom="16dp"
- app:layout_constraintBottom_toBottomOf="parent"
+ android:src="@drawable/ic_contact_picture"
app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent"
- android:src="@drawable/ic_contact_picture" />
+ app:layout_constraintTop_toTopOf="parent" />
-
+ app:layout_goneMarginBottom="12dp"
+ tools:text="Alice" />
-
+ tools:text="alice@domain.example" />
@@ -81,29 +75,30 @@
diff --git a/app/ui/legacy/src/main/res/layout/message_details_section_header_item.xml b/app/ui/legacy/src/main/res/layout/message_details_section_header_item.xml
new file mode 100644
index 000000000..9e3c855a8
--- /dev/null
+++ b/app/ui/legacy/src/main/res/layout/message_details_section_header_item.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
diff --git a/app/ui/legacy/src/main/res/menu/participant_overflow_menu.xml b/app/ui/legacy/src/main/res/menu/participant_overflow_menu.xml
new file mode 100644
index 000000000..b79ab3cc8
--- /dev/null
+++ b/app/ui/legacy/src/main/res/menu/participant_overflow_menu.xml
@@ -0,0 +1,9 @@
+
+
diff --git a/app/ui/legacy/src/main/res/values/attrs.xml b/app/ui/legacy/src/main/res/values/attrs.xml
index b98c894c9..001cd401c 100644
--- a/app/ui/legacy/src/main/res/values/attrs.xml
+++ b/app/ui/legacy/src/main/res/values/attrs.xml
@@ -110,6 +110,7 @@
+
diff --git a/app/ui/legacy/src/main/res/values/message_details_ids.xml b/app/ui/legacy/src/main/res/values/message_details_ids.xml
new file mode 100644
index 000000000..a87383ded
--- /dev/null
+++ b/app/ui/legacy/src/main/res/values/message_details_ids.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/app/ui/legacy/src/main/res/values/strings.xml b/app/ui/legacy/src/main/res/values/strings.xml
index a00a8e8dd..5b3476a1f 100644
--- a/app/ui/legacy/src/main/res/values/strings.xml
+++ b/app/ui/legacy/src/main/res/values/strings.xml
@@ -1307,4 +1307,36 @@ You can keep this message and use it as a backup for your secret key. If you wan
Error
Folder not found
+
+
+ Message details
+
+ Missing \'Date\' header
+
+ From
+
+ Sender
+
+ Reply to
+
+ To
+
+ Cc
+
+ Bcc
+
+
+ An error occurred while loading the message details.
+
+
+ Add to contacts
+
+ Copy email address
+
+ Copy name and email address
+
+
+ Email address
+
+ Name and email address
diff --git a/app/ui/legacy/src/main/res/values/themes.xml b/app/ui/legacy/src/main/res/values/themes.xml
index 5e4087128..2a64b98cc 100644
--- a/app/ui/legacy/src/main/res/values/themes.xml
+++ b/app/ui/legacy/src/main/res/values/themes.xml
@@ -134,6 +134,7 @@
- #fbbc04
- #ffffffff
- @drawable/ic_person_add
+ - #ffcccccc
- #e8e8e8
- #ffababab
- @array/contact_picture_fallback_background_colors_light
@@ -296,6 +297,7 @@
- #fdd663
- #000000
- @drawable/ic_person_add
+ - #ff555555
- #313131
- #313131
- #ff606060
diff --git a/backend/demo/src/main/resources/contents.json b/backend/demo/src/main/resources/contents.json
index 87879b8cc..03a7c033a 100644
--- a/backend/demo/src/main/resources/contents.json
+++ b/backend/demo/src/main/resources/contents.json
@@ -2,7 +2,7 @@
"inbox": {
"name": "Inbox",
"type": "INBOX",
- "messageServerIds": ["intro"]
+ "messageServerIds": ["intro", "many_recipients"]
},
"trash": {
"name": "Trash",
diff --git a/backend/demo/src/main/resources/inbox/many_recipients.eml b/backend/demo/src/main/resources/inbox/many_recipients.eml
new file mode 100644
index 000000000..a0bd8317d
--- /dev/null
+++ b/backend/demo/src/main/resources/inbox/many_recipients.eml
@@ -0,0 +1,42 @@
+MIME-Version: 1.0
+From: "Alice" , "Bob"
+Sender: "Bernd"
+Reply-To:
+Date: Mon, 23 Jan 2023 12:00:00 +0100
+Message-ID:
+Subject: Message details demo
+To: "User 1" ,
+ "User 2" ,
+ "User 3" ,
+ "User 4" ,
+ "User 5" ,
+ "User 6" ,
+ "User 7" ,
+ "User 8" ,
+ "User 9" ,
+ "User 10" ,
+ "User 11" ,
+ "User 12" ,
+ "User 13" ,
+ "User 14" ,
+ "User 15" ,
+ "User 16" ,
+ "User 17" ,
+ "User 18" ,
+ "User 19" ,
+ "User 20"
+Cc: "Copy 1" ,
+ "Copy 2" ,
+ "Copy 3"
+Bcc: "Blind 1" ,
+ "Blind 2" ,
+ "Blind 3"
+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
diff --git a/ui-utils/ToolbarBottomSheet/src/main/java/app/k9mail/ui/utils/bottomsheet/ToolbarBottomSheetDialog.kt b/ui-utils/ToolbarBottomSheet/src/main/java/app/k9mail/ui/utils/bottomsheet/ToolbarBottomSheetDialog.kt
index 8255a8acb..159f74ce4 100644
--- a/ui-utils/ToolbarBottomSheet/src/main/java/app/k9mail/ui/utils/bottomsheet/ToolbarBottomSheetDialog.kt
+++ b/ui-utils/ToolbarBottomSheet/src/main/java/app/k9mail/ui/utils/bottomsheet/ToolbarBottomSheetDialog.kt
@@ -34,7 +34,6 @@ import androidx.annotation.StyleRes
import androidx.appcompat.app.AppCompatDialog
import androidx.appcompat.widget.Toolbar
import androidx.coordinatorlayout.widget.CoordinatorLayout
-import androidx.core.graphics.Insets
import androidx.core.view.AccessibilityDelegateCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
@@ -191,6 +190,19 @@ class ToolbarBottomSheetDialog internal constructor(context: Context, @StyleRes
container.doOnLayout {
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
@@ -200,27 +212,15 @@ class ToolbarBottomSheetDialog internal constructor(context: Context, @StyleRes
private fun wrapInBottomSheet(view: View): View {
ensureContainerAndBehavior()
- val window = checkNotNull(window)
val container = checkNotNull(container)
val coordinator = checkNotNull(coordinator)
val bottomSheet = checkNotNull(bottomSheet)
- val toolbar = checkNotNull(toolbar)
ViewCompat.setOnApplyWindowInsetsListener(bottomSheet) { _, windowInsets ->
- val behavior = checkNotNull(internalBehavior)
-
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val topInset = insets.top
- toolbar.setPadding(0, insets.top, 0, 0)
-
- 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
+ setToolbarVisibilityCallback(topInset)
WindowInsetsCompat.CONSUMED
}
@@ -268,6 +268,23 @@ class ToolbarBottomSheetDialog internal constructor(context: Context, @StyleRes
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() {
checkNotNull(internalBehavior).removeBottomSheetCallback(cancelDialogCallback)
}
@@ -285,7 +302,7 @@ class ToolbarBottomSheetDialog internal constructor(context: Context, @StyleRes
private class ToolbarVisibilityCallback(
private val windowInsetsController: WindowInsetsControllerCompat,
private val toolbar: Toolbar,
- private val insets: Insets,
+ private val topInset: Int,
private val behavior: BottomSheetBehavior
) : LayoutAwareBottomSheetCallback() {
@@ -335,14 +352,14 @@ class ToolbarBottomSheetDialog internal constructor(context: Context, @StyleRes
val top = bottomSheet.top
val collapsedOffset = (bottomSheet.parent as View).height / 2
- if (top >= collapsedOffset) {
+ if (top >= collapsedOffset || behavior.expandedOffset > 0) {
toolbar.isInvisible = true
bottomSheet.setPadding(0, 0, 0, 0)
return
}
val toolbarHeight = toolbar.height - toolbar.paddingTop
- val toolbarHeightAndInset = toolbarHeight + insets.top
+ val toolbarHeightAndInset = toolbarHeight + topInset
val expandedPercentage =
((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)
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
// 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
toolbar.translationY = -paddingTop.toFloat()
- // Start fading in 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.
- val alphaPercentage =
- ((toolbarHeight - top).toFloat() / (toolbarHeight - behavior.expandedOffset)).coerceAtLeast(0f)
- toolbar.alpha = alphaPercentage
+ toolbar.alpha = toolbarPercentage
} else {
toolbar.isInvisible = true
}
}
private fun setStatusBarColor(bottomSheet: View) {
- val toolbarLightThreshold = insets.top / 2
+ val toolbarLightThreshold = topInset / 2
if (bottomSheet.top < toolbarLightThreshold) {
windowInsetsController.isAppearanceLightStatusBars = lightToolbar
} else if (bottomSheet.top != 0) {