Load message details
This commit is contained in:
parent
7dd0ed79c4
commit
9cb6859ad6
35 changed files with 972 additions and 150 deletions
|
@ -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")
|
||||||
|
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
package com.fsck.k9.ui.messagedetails
|
||||||
|
|
||||||
|
import com.fsck.k9.K9
|
||||||
|
|
||||||
|
class ContactSettingsProvider {
|
||||||
|
val isShowContactPicture: Boolean
|
||||||
|
get() = K9.isShowContactPicture
|
||||||
|
}
|
|
@ -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() }
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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>
|
|
||||||
|
|
|
@ -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" />
|
|
@ -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" />
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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"/>
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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",
|
||||||
|
|
42
backend/demo/src/main/resources/inbox/many_recipients.eml
Normal file
42
backend/demo/src/main/resources/inbox/many_recipients.eml
Normal 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
|
|
@ -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) {
|
||||||
|
|
Loading…
Reference in a new issue