diff --git a/app/core/build.gradle b/app/core/build.gradle index 9c8ca30796..46bba04847 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 2043e6bd5d..d2642f7d04 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 0000000000..19c14855f3 --- /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 aa88bc1e15..e2fc950b16 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 75c292e237..b7b5e88d61 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 ca1e7dec6a..aeecdc0352 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 c094ae3b3c..041dda7474 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 09dccb31d7..09246c3cb3 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 79f928ec36..9ae3e125bc 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 0000000000..399883c40c --- /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 0000000000..eefb56e8e0 --- /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 0000000000..75f6cd5957 --- /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 0000000000..0c5a52d7f3 --- /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 0000000000..9706b2d4b2 --- /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 0000000000..5f877dc215 --- /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 0000000000..e157e41a97 --- /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 0000000000..e67ee44fce --- /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 0000000000..51800bd8c0 --- /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 0000000000..487aba7290 --- /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 0000000000..b6ef5d799a --- /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 a32bc6a531..0000000000 --- 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 dbe5106113..acb52cf40a 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 df07b9a486..b97430c3e0 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 0000000000..2888a4294a --- /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 0000000000..8a456ec9a6 --- /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 b8cfc50341..ce99f941ff 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 0000000000..9e3c855a8d --- /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 0000000000..b79ab3cc82 --- /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 b98c894c9d..001cd401c3 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 0000000000..a87383ded3 --- /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 a00a8e8dd3..5b3476a1f6 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 5e40871284..2a64b98cc8 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 87879b8cc2..03a7c033a1 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 0000000000..a0bd8317d8 --- /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 8255a8acb6..159f74ce47 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) {