diff --git a/app/core/build.gradle b/app/core/build.gradle index 9c8ca3079691ce9baf4a8455bd6de4319aa11474..46bba0484775f8318aac5869f46a627187521d9e 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/CoreKoinModules.kt b/app/core/src/main/java/com/fsck/k9/CoreKoinModules.kt index d79482d55df87d0417276d70c7e5389a7e58027b..38991fcf0187ef52b452d36d53972ed437dcee79 100644 --- a/app/core/src/main/java/com/fsck/k9/CoreKoinModules.kt +++ b/app/core/src/main/java/com/fsck/k9/CoreKoinModules.kt @@ -15,14 +15,12 @@ import com.fsck.k9.network.connectivityModule import com.fsck.k9.notification.coreNotificationModule import com.fsck.k9.power.powerModule import com.fsck.k9.preferences.preferencesModule -import com.fsck.k9.search.searchModule val coreModules = listOf( mainModule, openPgpModule, autocryptModule, mailStoreModule, - searchModule, extractorModule, htmlModule, quoteModule, diff --git a/app/core/src/main/java/com/fsck/k9/FontSizes.java b/app/core/src/main/java/com/fsck/k9/FontSizes.java index 94e050f70718cb9403954741ffca9cf85022da49..f6c7ce7689c4778768337bc6d911492203bccfb7 100644 --- a/app/core/src/main/java/com/fsck/k9/FontSizes.java +++ b/app/core/src/main/java/com/fsck/k9/FontSizes.java @@ -16,10 +16,7 @@ public class FontSizes { private static final String MESSAGE_LIST_DATE = "fontSizeMessageListDate"; private static final String MESSAGE_LIST_PREVIEW = "fontSizeMessageListPreview"; private static final String MESSAGE_VIEW_SENDER = "fontSizeMessageViewSender"; - private static final String MESSAGE_VIEW_TO = "fontSizeMessageViewTo"; - private static final String MESSAGE_VIEW_CC = "fontSizeMessageViewCC"; - private static final String MESSAGE_VIEW_BCC = "fontSizeMessageViewBCC"; - private static final String MESSAGE_VIEW_ADDITIONAL_HEADERS = "fontSizeMessageViewAdditionalHeaders"; + private static final String MESSAGE_VIEW_RECIPIENTS = "fontSizeMessageViewTo"; private static final String MESSAGE_VIEW_SUBJECT = "fontSizeMessageViewSubject"; private static final String MESSAGE_VIEW_DATE = "fontSizeMessageViewDate"; private static final String MESSAGE_VIEW_CONTENT_PERCENT = "fontSizeMessageViewContentPercent"; @@ -40,10 +37,7 @@ public class FontSizes { private int messageListDate; private int messageListPreview; private int messageViewSender; - private int messageViewTo; - private int messageViewCC; - private int messageViewBCC; - private int messageViewAdditionalHeaders; + private int messageViewRecipients; private int messageViewSubject; private int messageViewDate; private int messageViewContentPercent; @@ -57,10 +51,7 @@ public class FontSizes { messageListPreview = FONT_DEFAULT; messageViewSender = FONT_DEFAULT; - messageViewTo = FONT_DEFAULT; - messageViewCC = FONT_DEFAULT; - messageViewBCC = FONT_DEFAULT; - messageViewAdditionalHeaders = FONT_DEFAULT; + messageViewRecipients = FONT_DEFAULT; messageViewSubject = FONT_DEFAULT; messageViewDate = FONT_DEFAULT; messageViewContentPercent = 100; @@ -75,10 +66,7 @@ public class FontSizes { editor.putInt(MESSAGE_LIST_PREVIEW, messageListPreview); editor.putInt(MESSAGE_VIEW_SENDER, messageViewSender); - editor.putInt(MESSAGE_VIEW_TO, messageViewTo); - editor.putInt(MESSAGE_VIEW_CC, messageViewCC); - editor.putInt(MESSAGE_VIEW_BCC, messageViewBCC); - editor.putInt(MESSAGE_VIEW_ADDITIONAL_HEADERS, messageViewAdditionalHeaders); + editor.putInt(MESSAGE_VIEW_RECIPIENTS, messageViewRecipients); editor.putInt(MESSAGE_VIEW_SUBJECT, messageViewSubject); editor.putInt(MESSAGE_VIEW_DATE, messageViewDate); editor.putInt(MESSAGE_VIEW_CONTENT_PERCENT, getMessageViewContentAsPercent()); @@ -93,10 +81,7 @@ public class FontSizes { messageListPreview = storage.getInt(MESSAGE_LIST_PREVIEW, messageListPreview); messageViewSender = storage.getInt(MESSAGE_VIEW_SENDER, messageViewSender); - messageViewTo = storage.getInt(MESSAGE_VIEW_TO, messageViewTo); - messageViewCC = storage.getInt(MESSAGE_VIEW_CC, messageViewCC); - messageViewBCC = storage.getInt(MESSAGE_VIEW_BCC, messageViewBCC); - messageViewAdditionalHeaders = storage.getInt(MESSAGE_VIEW_ADDITIONAL_HEADERS, messageViewAdditionalHeaders); + messageViewRecipients = storage.getInt(MESSAGE_VIEW_RECIPIENTS, messageViewRecipients); messageViewSubject = storage.getInt(MESSAGE_VIEW_SUBJECT, messageViewSubject); messageViewDate = storage.getInt(MESSAGE_VIEW_DATE, messageViewDate); @@ -149,36 +134,12 @@ public class FontSizes { this.messageViewSender = messageViewSender; } - public int getMessageViewTo() { - return messageViewTo; + public int getMessageViewRecipients() { + return messageViewRecipients; } - public void setMessageViewTo(int messageViewTo) { - this.messageViewTo = messageViewTo; - } - - public int getMessageViewCC() { - return messageViewCC; - } - - public void setMessageViewCC(int messageViewCC) { - this.messageViewCC = messageViewCC; - } - - public int getMessageViewBCC() { - return messageViewBCC; - } - - public void setMessageViewBCC(int messageViewBCC) { - this.messageViewBCC = messageViewBCC; - } - - public int getMessageViewAdditionalHeaders() { - return messageViewAdditionalHeaders; - } - - public void setMessageViewAdditionalHeaders(int messageViewAdditionalHeaders) { - this.messageViewAdditionalHeaders = messageViewAdditionalHeaders; + public void setMessageViewRecipients(int messageViewRecipients) { + this.messageViewRecipients = messageViewRecipients; } public int getMessageViewSubject() { diff --git a/app/core/src/main/java/com/fsck/k9/SettingsChangeListener.kt b/app/core/src/main/java/com/fsck/k9/SettingsChangeListener.kt deleted file mode 100644 index 119e192f0e2dd1211ad27ea5cf4cdb46ca590d66..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/SettingsChangeListener.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.fsck.k9 - -fun interface SettingsChangeListener { - fun onSettingsChanged() -} diff --git a/app/core/src/main/java/com/fsck/k9/controller/KoinModule.kt b/app/core/src/main/java/com/fsck/k9/controller/KoinModule.kt index 893ef7ab3665f857ef6387977c2596977d08c9d7..b62e67ebf14ee651684b2ca4e62335841af18505 100644 --- a/app/core/src/main/java/com/fsck/k9/controller/KoinModule.kt +++ b/app/core/src/main/java/com/fsck/k9/controller/KoinModule.kt @@ -19,7 +19,6 @@ val controllerModule = module { get(), get(), get(), - get(), get(), get(), get(), @@ -31,8 +30,7 @@ val controllerModule = module { single { DefaultMessageCountsProvider( preferences = get(), - accountSearchConditions = get(), - localStoreProvider = get() + messageStoreManager = get() ) } } diff --git a/app/core/src/main/java/com/fsck/k9/controller/MessageCountsProvider.kt b/app/core/src/main/java/com/fsck/k9/controller/MessageCountsProvider.kt index 4cfe1e1d590c4b32a8efb508e095bb4ffa43c1cb..1d7e5f27c03b311c3c4e6bc46c6d61db3be08be0 100644 --- a/app/core/src/main/java/com/fsck/k9/controller/MessageCountsProvider.kt +++ b/app/core/src/main/java/com/fsck/k9/controller/MessageCountsProvider.kt @@ -2,39 +2,34 @@ package com.fsck.k9.controller import com.fsck.k9.Account import com.fsck.k9.Preferences -import com.fsck.k9.mail.MessagingException -import com.fsck.k9.mailstore.LocalStoreProvider -import com.fsck.k9.search.AccountSearchConditions +import com.fsck.k9.mailstore.MessageStoreManager +import com.fsck.k9.search.ConditionsTreeNode import com.fsck.k9.search.LocalSearch import com.fsck.k9.search.SearchAccount +import com.fsck.k9.search.excludeSpecialFolders import com.fsck.k9.search.getAccounts +import com.fsck.k9.search.limitToDisplayableFolders import timber.log.Timber interface MessageCountsProvider { fun getMessageCounts(account: Account): MessageCounts fun getMessageCounts(searchAccount: SearchAccount): MessageCounts + fun getUnreadMessageCount(account: Account, folderId: Long): Int } data class MessageCounts(val unread: Int, val starred: Int) internal class DefaultMessageCountsProvider( private val preferences: Preferences, - private val accountSearchConditions: AccountSearchConditions, - private val localStoreProvider: LocalStoreProvider + private val messageStoreManager: MessageStoreManager ) : MessageCountsProvider { override fun getMessageCounts(account: Account): MessageCounts { - return try { - val localStore = localStoreProvider.getInstance(account) - - val search = LocalSearch() - accountSearchConditions.excludeSpecialFolders(account, search) - accountSearchConditions.limitToDisplayableFolders(account, search) - - localStore.getMessageCounts(search) - } catch (e: MessagingException) { - Timber.e(e, "Unable to getMessageCounts for account: %s", account) - MessageCounts(0, 0) + val search = LocalSearch().apply { + excludeSpecialFolders(account) + limitToDisplayableFolders(account) } + + return getMessageCounts(account, search.conditions) } override fun getMessageCounts(searchAccount: SearchAccount): MessageCounts { @@ -44,7 +39,7 @@ internal class DefaultMessageCountsProvider( var unreadCount = 0 var starredCount = 0 for (account in accounts) { - val accountMessageCount = getMessageCountsWithLocalSearch(account, search) + val accountMessageCount = getMessageCounts(account, search.conditions) unreadCount += accountMessageCount.unread starredCount += accountMessageCount.starred } @@ -52,13 +47,30 @@ internal class DefaultMessageCountsProvider( return MessageCounts(unreadCount, starredCount) } - private fun getMessageCountsWithLocalSearch(account: Account, search: LocalSearch): MessageCounts { + override fun getUnreadMessageCount(account: Account, folderId: Long): Int { + return try { + val messageStore = messageStoreManager.getMessageStore(account) + return if (folderId == account.outboxFolderId) { + messageStore.getMessageCount(folderId) + } else { + messageStore.getUnreadMessageCount(folderId) + } + } catch (e: Exception) { + Timber.e(e, "Unable to getUnreadMessageCount for account: %s, folder: %d", account, folderId) + 0 + } + } + + private fun getMessageCounts(account: Account, conditions: ConditionsTreeNode): MessageCounts { return try { - val localStore = localStoreProvider.getInstance(account) - localStore.getMessageCounts(search) - } catch (e: MessagingException) { + val messageStore = messageStoreManager.getMessageStore(account) + return MessageCounts( + unread = messageStore.getUnreadMessageCount(conditions), + starred = messageStore.getStarredMessageCount(conditions) + ) + } catch (e: Exception) { Timber.e(e, "Unable to getMessageCounts for account: %s", account) - MessageCounts(0, 0) + MessageCounts(unread = 0, starred = 0) } } } diff --git a/app/core/src/main/java/com/fsck/k9/controller/MessagingController.java b/app/core/src/main/java/com/fsck/k9/controller/MessagingController.java index ba02065804094552b3b7ad791a0ee27ad1fd8a09..a5c8f9f5f52f3ea7dd3db07bbf8323f0c0be4d92 100644 --- a/app/core/src/main/java/com/fsck/k9/controller/MessagingController.java +++ b/app/core/src/main/java/com/fsck/k9/controller/MessagingController.java @@ -21,7 +21,6 @@ import java.util.concurrent.Future; import java.util.concurrent.PriorityBlockingQueue; import java.util.concurrent.atomic.AtomicInteger; -import android.annotation.SuppressLint; import android.content.Context; import android.os.Process; import android.os.SystemClock; @@ -83,7 +82,6 @@ import com.fsck.k9.mailstore.SpecialLocalFoldersCreator; import com.fsck.k9.notification.NotificationController; import com.fsck.k9.notification.NotificationStrategy; import com.fsck.k9.search.LocalSearch; -import com.fsck.k9.search.SearchAccount; import com.fsck.k9.setup.EeloAccountHelper; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -128,12 +126,10 @@ public class MessagingController { private final Set listeners = new CopyOnWriteArraySet<>(); private final ExecutorService threadPool = Executors.newCachedThreadPool(); private final MemorizingMessagingListener memorizingMessagingListener = new MemorizingMessagingListener(); - private final MessageCountsProvider messageCountsProvider; private final DraftOperations draftOperations; private final NotificationOperations notificationOperations; - private MessagingListener checkMailListener = null; private volatile boolean stopped = false; @@ -144,15 +140,13 @@ public class MessagingController { MessagingController(Context context, NotificationController notificationController, NotificationStrategy notificationStrategy, LocalStoreProvider localStoreProvider, - MessageCountsProvider messageCountsProvider, BackendManager backendManager, - Preferences preferences, MessageStoreManager messageStoreManager, + BackendManager backendManager, Preferences preferences, MessageStoreManager messageStoreManager, SaveMessageDataCreator saveMessageDataCreator, SpecialLocalFoldersCreator specialLocalFoldersCreator, List controllerExtensions) { this.context = context; this.notificationController = notificationController; this.notificationStrategy = notificationStrategy; this.localStoreProvider = localStoreProvider; - this.messageCountsProvider = messageCountsProvider; this.backendManager = backendManager; this.preferences = preferences; this.messageStoreManager = messageStoreManager; @@ -508,18 +502,24 @@ public class MessagingController { } } + public void loadMoreMessages(Account account, long folderId) { + putBackground("loadMoreMessages", null, () -> loadMoreMessagesSynchronous(account, folderId)); + } - public void loadMoreMessages(Account account, long folderId, MessagingListener listener) { - try { - LocalStore localStore = localStoreProvider.getInstance(account); - LocalFolder localFolder = localStore.getFolder(folderId); - if (localFolder.getVisibleLimit() > 0) { - localFolder.setVisibleLimit(localFolder.getVisibleLimit() + account.getDisplayCount()); - } - synchronizeMailbox(account, folderId, false, listener); - } catch (MessagingException me) { - throw new RuntimeException("Unable to set visible limit on folder", me); + public void loadMoreMessagesSynchronous(Account account, long folderId) { + MessageStore messageStore = messageStoreManager.getMessageStore(account); + Integer visibleLimit = messageStore.getFolder(folderId, FolderDetailsAccessor::getVisibleLimit); + if (visibleLimit == null) { + Timber.v("loadMoreMessages(%s, %d): Folder not found", account, folderId); + return; + } + + if (visibleLimit > 0) { + int newVisibleLimit = visibleLimit + account.getDisplayCount(); + messageStore.setVisibleLimit(folderId, newVisibleLimit); } + + synchronizeMailboxSynchronous(account, folderId, false, null, new NotificationState()); } /** @@ -1198,16 +1198,6 @@ public class MessagingController { } } - public void clearAllPending(final Account account) { - try { - Timber.w("Clearing pending commands!"); - LocalStore localStore = localStoreProvider.getInstance(account); - localStore.removePendingCommands(); - } catch (MessagingException me) { - Timber.e(me, "Unable to clear pending command"); - } - } - public void loadMessageRemotePartial(Account account, long folderId, String uid, MessagingListener listener) { put("loadMessageRemotePartial", listener, () -> loadMessageRemoteSynchronous(account, folderId, uid, listener, true) @@ -1672,22 +1662,6 @@ public class MessagingController { } } - public int getUnreadMessageCount(Account account) { - MessageCounts messageCounts = messageCountsProvider.getMessageCounts(account); - return messageCounts.getUnread(); - } - - public int getUnreadMessageCount(SearchAccount searchAccount) { - MessageCounts messageCounts = messageCountsProvider.getMessageCounts(searchAccount); - return messageCounts.getUnread(); - } - - public int getFolderUnreadMessageCount(Account account, Long folderId) throws MessagingException { - LocalStore localStore = localStoreProvider.getInstance(account); - LocalFolder localFolder = localStore.getFolder(folderId); - return localFolder.getUnreadMessageCount(); - } - public boolean isMoveCapable(MessageReference messageReference) { return !messageReference.getUid().startsWith(K9.LOCAL_UID_PREFIX); } @@ -1997,35 +1971,6 @@ public class MessagingController { }); } - @SuppressLint("NewApi") // used for debugging only - public void debugClearMessagesLocally(final List messages) { - if (!K9.DEVELOPER_MODE) { - throw new AssertionError("method must only be used in developer mode!"); - } - - actOnMessagesGroupedByAccountAndFolder(messages, new MessageActor() { - - @Override - public void act(final Account account, final LocalFolder messageFolder, - final List accountMessages) { - - putBackground("debugClearLocalMessages", null, new Runnable() { - @Override - public void run() { - for (LocalMessage message : accountMessages) { - try { - message.debugClearLocalData(); - } catch (MessagingException e) { - throw new AssertionError("clearing local message content failed!", e); - } - } - } - }); - } - }); - - } - private void deleteMessagesSynchronous(Account account, long folderId, List messages, boolean skipTrashFolder) { try { List localOnlyMessages = new ArrayList<>(); @@ -2394,10 +2339,6 @@ public class MessagingController { Timber.v("Clearing notification flag for %s", account); clearFetchingMailNotification(account); - - if (getUnreadMessageCount(account) == 0) { - notificationController.clearNewMailNotifications(account, false); - } } } ); @@ -2516,20 +2457,6 @@ public class MessagingController { } } - public MessagingListener getCheckMailListener() { - return checkMailListener; - } - - public void setCheckMailListener(MessagingListener checkMailListener) { - if (this.checkMailListener != null) { - removeListener(this.checkMailListener); - } - this.checkMailListener = checkMailListener; - if (this.checkMailListener != null) { - addListener(this.checkMailListener); - } - } - public void clearNotifications(LocalSearch search) { put("clearNotifications", null, () -> { notificationOperations.clearNotifications(search); diff --git a/app/core/src/main/java/com/fsck/k9/helper/AddressFormatter.kt b/app/core/src/main/java/com/fsck/k9/helper/AddressFormatter.kt new file mode 100644 index 0000000000000000000000000000000000000000..1e004aff101d907314d2e3eb9e78a918e87c48ce --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/helper/AddressFormatter.kt @@ -0,0 +1,74 @@ +package com.fsck.k9.helper + +import android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import com.fsck.k9.Account +import com.fsck.k9.Identity +import com.fsck.k9.mail.Address + +/** + * Get the display name for an email address. + */ +interface AddressFormatter { + fun getDisplayName(address: Address): CharSequence +} + +class RealAddressFormatter( + private val contactNameProvider: ContactNameProvider, + private val account: Account, + private val showCorrespondentNames: Boolean, + private val showContactNames: Boolean, + private val contactNameColor: Int?, + private val meText: String +) : AddressFormatter { + override fun getDisplayName(address: Address): CharSequence { + val identity = account.findIdentity(address) + if (identity != null) { + return getIdentityName(identity) + } + + return if (!showCorrespondentNames) { + address.address + } else if (showContactNames) { + getContactName(address) + } else { + buildDisplayName(address) + } + } + + private fun getIdentityName(identity: Identity): String { + return if (account.identities.size == 1) { + meText + } else { + identity.description ?: identity.name ?: identity.email ?: meText + } + } + + private fun getContactName(address: Address): CharSequence { + val contactName = contactNameProvider.getNameForAddress(address.address) ?: return buildDisplayName(address) + + return if (contactNameColor != null) { + SpannableString(contactName).apply { + setSpan(ForegroundColorSpan(contactNameColor), 0, contactName.length, SPAN_EXCLUSIVE_EXCLUSIVE) + } + } else { + contactName + } + } + + private fun buildDisplayName(address: Address): CharSequence { + return address.personal?.takeIf { + it.isNotBlank() && !it.equals(meText, ignoreCase = true) && !isSpoofAddress(it) + } ?: address.address + } + + private fun isSpoofAddress(displayName: String): Boolean { + val atIndex = displayName.indexOf('@') + return if (atIndex > 0) { + displayName[atIndex - 1] != '(' + } else { + false + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/helper/ContactNameProvider.kt b/app/core/src/main/java/com/fsck/k9/helper/ContactNameProvider.kt new file mode 100644 index 0000000000000000000000000000000000000000..4abbb732d8fd5cc4163a19dc337d3dbcc5b5a9c7 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/helper/ContactNameProvider.kt @@ -0,0 +1,11 @@ +package com.fsck.k9.helper + +interface ContactNameProvider { + fun getNameForAddress(address: String): String? +} + +class RealContactNameProvider(private val contacts: Contacts) : ContactNameProvider { + override fun getNameForAddress(address: String): String? { + return contacts.getNameForAddress(address) + } +} 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 2043e6bd5d8c2d2149545335d9e7f849f1bfbdb6..d2642f7d04438e6a01e8400fc630f1d454552166 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.core.content.ContextCompat; +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/LocalFolder.java b/app/core/src/main/java/com/fsck/k9/mailstore/LocalFolder.java index 98ba7ba9f3ea22ab33c0c33815cc304504efe571..45babff846f82f73ebba9012f57d9d6146a7f453 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/LocalFolder.java +++ b/app/core/src/main/java/com/fsck/k9/mailstore/LocalFolder.java @@ -269,11 +269,6 @@ public class LocalFolder { }); } - public int getVisibleLimit() throws MessagingException { - open(); - return visibleLimit; - } - public void setVisibleLimit(final int visibleLimit) throws MessagingException { updateMoreMessagesOnVisibleLimitChange(visibleLimit, this.visibleLimit); diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/LocalStore.java b/app/core/src/main/java/com/fsck/k9/mailstore/LocalStore.java index 01af19471925933612cc64ca48cbae20b07cff19..27e3e11736e9a1d94074986385d0c483ce4618ca 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/LocalStore.java +++ b/app/core/src/main/java/com/fsck/k9/mailstore/LocalStore.java @@ -29,7 +29,6 @@ import androidx.core.database.CursorKt; import com.fsck.k9.Account; import com.fsck.k9.Clock; import com.fsck.k9.DI; -import com.fsck.k9.controller.MessageCounts; import com.fsck.k9.Preferences; import com.fsck.k9.controller.MessagingControllerCommands.PendingCommand; import com.fsck.k9.controller.PendingCommandSerializer; @@ -355,7 +354,7 @@ public class LocalStore { public List searchForMessages(LocalSearch search) throws MessagingException { StringBuilder query = new StringBuilder(); List queryArgs = new ArrayList<>(); - SqlQueryBuilder.buildWhereClause(account, search.getConditions(), query, queryArgs); + SqlQueryBuilder.buildWhereClause(search.getConditions(), query, queryArgs); // Avoid "ambiguous column name" error by prefixing "id" with the message table name String where = SqlQueryBuilder.addPrefixToSelection(new String[] { "id" }, @@ -1005,72 +1004,6 @@ public class LocalStore { return folderMap; } - public int getUnreadMessageCount(LocalSearch search) throws MessagingException { - StringBuilder whereBuilder = new StringBuilder(); - List queryArgs = new ArrayList<>(); - SqlQueryBuilder.buildWhereClause(account, search.getConditions(), whereBuilder, queryArgs); - - String where = whereBuilder.toString(); - final String[] selectionArgs = queryArgs.toArray(new String[queryArgs.size()]); - - final String sqlQuery = "SELECT SUM(read=0) " + - "FROM messages " + - "JOIN folders ON (folders.id = messages.folder_id) " + - "WHERE (messages.empty = 0 AND messages.deleted = 0)" + - (!TextUtils.isEmpty(where) ? " AND (" + where + ")" : ""); - - return database.execute(false, new DbCallback() { - @Override - public Integer doDbWork(SQLiteDatabase db) { - Cursor cursor = db.rawQuery(sqlQuery, selectionArgs); - try { - if (cursor.moveToFirst()) { - return cursor.getInt(0); - } else { - return 0; - } - } finally { - cursor.close(); - } - } - }); - } - - private int getStarredMessageCount(LocalSearch search) throws MessagingException { - StringBuilder whereBuilder = new StringBuilder(); - List queryArgs = new ArrayList<>(); - SqlQueryBuilder.buildWhereClause(account, search.getConditions(), whereBuilder, queryArgs); - - String where = whereBuilder.toString(); - final String[] selectionArgs = queryArgs.toArray(new String[queryArgs.size()]); - - final String sqlQuery = "SELECT SUM(flagged=1) " + - "FROM messages " + - "JOIN folders ON (folders.id = messages.folder_id) " + - "WHERE (messages.empty = 0 AND messages.deleted = 0)" + - (!TextUtils.isEmpty(where) ? " AND (" + where + ")" : ""); - - return database.execute(false, new DbCallback() { - @Override - public Integer doDbWork(SQLiteDatabase db) { - Cursor cursor = db.rawQuery(sqlQuery, selectionArgs); - try { - if (cursor.moveToFirst()) { - return cursor.getInt(0); - } else { - return 0; - } - } finally { - cursor.close(); - } - } - }); - } - - public MessageCounts getMessageCounts(LocalSearch search) throws MessagingException { - return new MessageCounts(getUnreadMessageCount(search), getStarredMessageCount(search)); - } - public List getNotificationMessages() throws MessagingException { return database.execute(false, db -> { try (Cursor cursor = db.rawQuery( diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/LockableDatabase.java b/app/core/src/main/java/com/fsck/k9/mailstore/LockableDatabase.java index 72efef185de4f74fbab6a30074bb16a4e97c6e53..69a8652aa95709dd13fd7a61e8d3a4a2da23d7b1 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/LockableDatabase.java +++ b/app/core/src/main/java/com/fsck/k9/mailstore/LockableDatabase.java @@ -324,10 +324,6 @@ public class LockableDatabase { delete(false); } - public void recreate() { - delete(true); - } - /** * @param recreate * true if the DB should be recreated after delete diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/LockableDatabaseExtensions.kt b/app/core/src/main/java/com/fsck/k9/mailstore/LockableDatabaseExtensions.kt deleted file mode 100644 index 9347f797c413701d951e3edfedafd1ec6d131623..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/mailstore/LockableDatabaseExtensions.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.fsck.k9.mailstore - -import android.database.Cursor - -internal fun LockableDatabase.query( - table: String, - columns: Array, - selection: String?, - vararg selectionArgs: String, - block: (Cursor) -> T -): T { - return execute(false) { db -> - val cursor = db.query(table, columns, selection, selectionArgs, null, null, null) - cursor.use(block) - } -} - -internal fun LockableDatabase.rawQuery(sql: String, vararg selectionArgs: String, block: (Cursor) -> T): T { - return execute(false) { db -> - val cursor = db.rawQuery(sql, selectionArgs) - cursor.use(block) - } -} 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 0000000000000000000000000000000000000000..19c14855f3e02ae3176ee85b4f943afa07850ab2 --- /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 aa88bc1e15d76cf4e35e3e8b1e58012289696337..e2fc950b16e3b13206e118a36438f7da244d5afa 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 80d3dd32bc2d30096ddea89c4c4ddfd7dbee370b..a93c705ee5c0ff87f75029a20d7152d1fc0468fb 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 @@ -5,6 +5,7 @@ import com.fsck.k9.mail.Flag import com.fsck.k9.mail.FolderClass import com.fsck.k9.mail.FolderType import com.fsck.k9.mail.Header +import com.fsck.k9.search.ConditionsTreeNode import java.util.Date /** @@ -155,6 +156,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. */ @@ -221,6 +227,21 @@ interface MessageStore { */ fun getMessageCount(folderId: Long): Int + /** + * Retrieve the number of unread messages in a folder. + */ + fun getUnreadMessageCount(folderId: Long): Int + + /** + * Retrieve the number of unread messages matching [conditions]. + */ + fun getUnreadMessageCount(conditions: ConditionsTreeNode): Int + + /** + * Retrieve the number of starred messages matching [conditions]. + */ + fun getStarredMessageCount(conditions: ConditionsTreeNode): Int + /** * Update a folder's name and type. */ @@ -276,6 +297,11 @@ interface MessageStore { */ fun setStatus(folderId: Long, status: String?) + /** + * Update a folder's "visible limit" value. + */ + fun setVisibleLimit(folderId: Long, visibleLimit: Int) + /** * Delete folders. */ diff --git a/app/core/src/main/java/com/fsck/k9/message/ReplyActionStrategy.kt b/app/core/src/main/java/com/fsck/k9/message/ReplyActionStrategy.kt new file mode 100644 index 0000000000000000000000000000000000000000..a208b982093a8a246b315902fe925974fee761e6 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/message/ReplyActionStrategy.kt @@ -0,0 +1,36 @@ +package com.fsck.k9.message + +import com.fsck.k9.Account +import com.fsck.k9.helper.ReplyToParser +import com.fsck.k9.mail.Message + +/** + * Figures out which reply actions are available to the user. + */ +class ReplyActionStrategy(private val replyRoParser: ReplyToParser) { + fun getReplyActions(account: Account, message: Message): ReplyActions { + val recipientsToReplyTo = replyRoParser.getRecipientsToReplyTo(message, account) + val recipientsToReplyAllTo = replyRoParser.getRecipientsToReplyAllTo(message, account) + + val replyToAllCount = recipientsToReplyAllTo.to.size + recipientsToReplyAllTo.cc.size + return if (replyToAllCount <= 1) { + if (recipientsToReplyTo.to.isEmpty()) { + ReplyActions(defaultAction = null) + } else { + ReplyActions(defaultAction = ReplyAction.REPLY) + } + } else { + ReplyActions(defaultAction = ReplyAction.REPLY_ALL, additionalActions = listOf(ReplyAction.REPLY)) + } + } +} + +data class ReplyActions( + val defaultAction: ReplyAction?, + val additionalActions: List = emptyList() +) + +enum class ReplyAction { + REPLY, + REPLY_ALL +} diff --git a/app/core/src/main/java/com/fsck/k9/message/html/HtmlConverter.kt b/app/core/src/main/java/com/fsck/k9/message/html/HtmlConverter.kt index 826cabc2b5d72fa6b521c5994c470463c42adc5e..4bca85212337d5bde2f35043345e6d3594a203a2 100644 --- a/app/core/src/main/java/com/fsck/k9/message/html/HtmlConverter.kt +++ b/app/core/src/main/java/com/fsck/k9/message/html/HtmlConverter.kt @@ -49,15 +49,4 @@ object HtmlConverter { fun textToHtmlFragment(text: String): String { return TextToHtml.toHtmlFragment(text, retainOriginalWhitespace = false) } - - /** - * Convert a plain text string into an HTML fragment. - * - * This does not convert consecutive spaces to a series of non-breaking spaces followed by a regular space. - * Only use this in combination with CSS to properly display the whitespace. - */ - @JvmStatic - fun textToHtmlFragmentWithOriginalWhitespace(text: String): String { - return TextToHtml.toHtmlFragment(text, retainOriginalWhitespace = true) - } } diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationResourceProvider.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationResourceProvider.kt index d33055a7af8e679731b882199fc8fc40c41eaa97..ca15c49ae66aeb50af6d3499e936233e23df010c 100644 --- a/app/core/src/main/java/com/fsck/k9/notification/NotificationResourceProvider.kt +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationResourceProvider.kt @@ -31,8 +31,6 @@ interface NotificationResourceProvider { fun certificateErrorTitle(accountName: String): String fun certificateErrorBody(): String - fun newMailTitle(): String - fun newMailUnreadMessageCount(unreadMessageCount: Int, accountName: String): String fun newMessagesTitle(newMessagesCount: Int): String fun additionalMessages(overflowMessagesCount: Int, accountName: String): String fun previewEncrypted(): String diff --git a/app/core/src/main/java/com/fsck/k9/search/AccountSearchConditions.kt b/app/core/src/main/java/com/fsck/k9/search/AccountSearchConditions.kt index 536716d1ab09342015d55b59106aedb397e56d65..51e80f8d95173fd66bf6191510d6d6bd8d20b979 100644 --- a/app/core/src/main/java/com/fsck/k9/search/AccountSearchConditions.kt +++ b/app/core/src/main/java/com/fsck/k9/search/AccountSearchConditions.kt @@ -7,105 +7,69 @@ import com.fsck.k9.search.SearchSpecification.Attribute import com.fsck.k9.search.SearchSpecification.SearchCondition import com.fsck.k9.search.SearchSpecification.SearchField -class AccountSearchConditions { - /** - * Modify the supplied [LocalSearch] instance to limit the search to displayable folders. - * - * This method uses the current folder display mode to decide what folders to include/exclude. - * - * @param search - * The `LocalSearch` instance to modify. - * - * @see .getFolderDisplayMode - */ - fun limitToDisplayableFolders(account: Account, search: LocalSearch) { - val displayMode = account.folderDisplayMode - - when (displayMode) { - FolderMode.FIRST_CLASS -> { - // Count messages in the INBOX and non-special first class folders - search.and(SearchField.DISPLAY_CLASS, FolderClass.FIRST_CLASS.name, Attribute.EQUALS) - } - FolderMode.FIRST_AND_SECOND_CLASS -> { - // Count messages in the INBOX and non-special first and second class folders - search.and(SearchField.DISPLAY_CLASS, FolderClass.FIRST_CLASS.name, Attribute.EQUALS) +/** + * Modify the supplied [LocalSearch] instance to limit the search to displayable folders. + * + * This method uses the current [folder display mode][Account.folderDisplayMode] to decide what folders to + * include/exclude. + */ +fun LocalSearch.limitToDisplayableFolders(account: Account) { + when (account.folderDisplayMode) { + FolderMode.FIRST_CLASS -> { + // Count messages in the INBOX and non-special first class folders + and(SearchField.DISPLAY_CLASS, FolderClass.FIRST_CLASS.name, Attribute.EQUALS) + } + FolderMode.FIRST_AND_SECOND_CLASS -> { + // Count messages in the INBOX and non-special first and second class folders + and(SearchField.DISPLAY_CLASS, FolderClass.FIRST_CLASS.name, Attribute.EQUALS) - // TODO: Create a proper interface for creating arbitrary condition trees - val searchCondition = SearchCondition( - SearchField.DISPLAY_CLASS, Attribute.EQUALS, FolderClass.SECOND_CLASS.name - ) - val root = search.conditions - if (root.mRight != null) { - root.mRight.or(searchCondition) - } else { - search.or(searchCondition) - } - } - FolderMode.NOT_SECOND_CLASS -> { - // Count messages in the INBOX and non-special non-second-class folders - search.and(SearchField.DISPLAY_CLASS, FolderClass.SECOND_CLASS.name, Attribute.NOT_EQUALS) - } - FolderMode.ALL, FolderMode.NONE -> { - // Count messages in the INBOX and non-special folders + // TODO: Create a proper interface for creating arbitrary condition trees + val searchCondition = SearchCondition( + SearchField.DISPLAY_CLASS, Attribute.EQUALS, FolderClass.SECOND_CLASS.name + ) + val root = conditions + if (root.mRight != null) { + root.mRight.or(searchCondition) + } else { + or(searchCondition) } } - } - - /** - * Modify the supplied [LocalSearch] instance to exclude special folders. - * - * Currently the following folders are excluded: - * - * * Trash - * * Drafts - * * Spam - * * Outbox - * * Sent - * - * The Inbox will always be included even if one of the special folders is configured to point - * to the Inbox. - * - * @param search - * The `LocalSearch` instance to modify. - */ - fun excludeSpecialFolders(account: Account, search: LocalSearch) { - excludeSpecialFolder(search, account.trashFolderId) - excludeSpecialFolder(search, account.draftsFolderId) - excludeSpecialFolder(search, account.spamFolderId) - excludeSpecialFolder(search, account.outboxFolderId) - excludeSpecialFolder(search, account.sentFolderId) - account.inboxFolderId?.let { inboxFolderId -> - search.or(SearchCondition(SearchField.FOLDER, Attribute.EQUALS, inboxFolderId.toString())) + FolderMode.NOT_SECOND_CLASS -> { + // Count messages in the INBOX and non-special non-second-class folders + and(SearchField.DISPLAY_CLASS, FolderClass.SECOND_CLASS.name, Attribute.NOT_EQUALS) + } + FolderMode.ALL, FolderMode.NONE -> { + // Count messages in the INBOX and non-special folders } } +} - /** - * Modify the supplied [LocalSearch] instance to exclude "unwanted" folders. - * - * Currently the following folders are excluded: - * - * * Trash - * * Spam - * * Outbox - * - * The Inbox will always be included even if one of the special folders is configured to point - * to the Inbox. - * - * @param search - * The `LocalSearch` instance to modify. - */ - fun excludeUnwantedFolders(account: Account, search: LocalSearch) { - excludeSpecialFolder(search, account.trashFolderId) - excludeSpecialFolder(search, account.spamFolderId) - excludeSpecialFolder(search, account.outboxFolderId) - account.inboxFolderId?.let { inboxFolderId -> - search.or(SearchCondition(SearchField.FOLDER, Attribute.EQUALS, inboxFolderId.toString())) - } +/** + * Modify the supplied [LocalSearch] instance to exclude special folders. + * + * Currently the following folders are excluded: + * - Trash + * - Drafts + * - Spam + * - Outbox + * - Sent + * + * The Inbox will always be included even if one of the special folders is configured to point to the Inbox. + */ +fun LocalSearch.excludeSpecialFolders(account: Account) { + this.excludeSpecialFolder(account.trashFolderId) + this.excludeSpecialFolder(account.draftsFolderId) + this.excludeSpecialFolder(account.spamFolderId) + this.excludeSpecialFolder(account.outboxFolderId) + this.excludeSpecialFolder(account.sentFolderId) + + account.inboxFolderId?.let { inboxFolderId -> + or(SearchCondition(SearchField.FOLDER, Attribute.EQUALS, inboxFolderId.toString())) } +} - private fun excludeSpecialFolder(search: LocalSearch, folderId: Long?) { - if (folderId != null) { - search.and(SearchField.FOLDER, folderId.toString(), Attribute.NOT_EQUALS) - } +private fun LocalSearch.excludeSpecialFolder(folderId: Long?) { + if (folderId != null) { + and(SearchField.FOLDER, folderId.toString(), Attribute.NOT_EQUALS) } } diff --git a/app/core/src/main/java/com/fsck/k9/search/KoinModule.kt b/app/core/src/main/java/com/fsck/k9/search/KoinModule.kt deleted file mode 100644 index 63dcc100f9e7b2f127307e5f34dce75a42f7797a..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/search/KoinModule.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.fsck.k9.search - -import org.koin.dsl.module - -val searchModule = module { - single { AccountSearchConditions() } -} diff --git a/app/core/src/main/java/com/fsck/k9/search/SearchSpecification.java b/app/core/src/main/java/com/fsck/k9/search/SearchSpecification.java index f97875167f59cd1e10d097a4c9ebca1f6f86a0db..f3ad2f0eafc03bfa3f88e71809b425f63005332a 100644 --- a/app/core/src/main/java/com/fsck/k9/search/SearchSpecification.java +++ b/app/core/src/main/java/com/fsck/k9/search/SearchSpecification.java @@ -71,8 +71,7 @@ public interface SearchSpecification extends Parcelable { NEW_MESSAGE, READ, FLAGGED, - DISPLAY_CLASS, - SEARCHABLE + DISPLAY_CLASS } diff --git a/app/core/src/main/java/com/fsck/k9/search/SqlQueryBuilder.java b/app/core/src/main/java/com/fsck/k9/search/SqlQueryBuilder.java index 4ce3bcc9a3453f2b0092a640b9c6146a2a2141d9..088e118a8429a81f8770c41c5d0095dfb041e2bb 100644 --- a/app/core/src/main/java/com/fsck/k9/search/SqlQueryBuilder.java +++ b/app/core/src/main/java/com/fsck/k9/search/SqlQueryBuilder.java @@ -2,85 +2,45 @@ package com.fsck.k9.search; import java.util.List; -import com.fsck.k9.DI; import timber.log.Timber; -import com.fsck.k9.Account; import com.fsck.k9.search.SearchSpecification.Attribute; import com.fsck.k9.search.SearchSpecification.SearchCondition; import com.fsck.k9.search.SearchSpecification.SearchField; public class SqlQueryBuilder { - public static void buildWhereClause(Account account, ConditionsTreeNode node, - StringBuilder query, List selectionArgs) { - buildWhereClauseInternal(account, node, query, selectionArgs); + public static void buildWhereClause(ConditionsTreeNode node, StringBuilder query, List selectionArgs) { + buildWhereClauseInternal(node, query, selectionArgs); } - private static void buildWhereClauseInternal(Account account, ConditionsTreeNode node, - StringBuilder query, List selectionArgs) { + private static void buildWhereClauseInternal(ConditionsTreeNode node, StringBuilder query, + List selectionArgs) { + if (node == null) { query.append("1"); return; } if (node.mLeft == null && node.mRight == null) { - AccountSearchConditions accountSearchConditions = DI.get(AccountSearchConditions.class); SearchCondition condition = node.mCondition; - switch (condition.field) { - case SEARCHABLE: { - switch (account.getSearchableFolders()) { - case ALL: { - // Create temporary LocalSearch object so we can use... - LocalSearch tempSearch = new LocalSearch(); - // ...the helper methods in Account to create the necessary conditions - // to exclude "unwanted" folders. - accountSearchConditions.excludeUnwantedFolders(account, tempSearch); - - buildWhereClauseInternal(account, tempSearch.getConditions(), query, - selectionArgs); - break; - } - case DISPLAYABLE: { - // Create temporary LocalSearch object so we can use... - LocalSearch tempSearch = new LocalSearch(); - // ...the helper methods in Account to create the necessary conditions - // to limit the selection to displayable, non-special folders. - accountSearchConditions.excludeSpecialFolders(account, tempSearch); - accountSearchConditions.limitToDisplayableFolders(account, tempSearch); - - buildWhereClauseInternal(account, tempSearch.getConditions(), query, - selectionArgs); - break; - } - case NONE: { - // Dummy condition, never select - query.append("0"); - break; - } - } - break; - } - case MESSAGE_CONTENTS: { - String fulltextQueryString = condition.value; - if (condition.attribute != Attribute.CONTAINS) { - Timber.e("message contents can only be matched!"); - } - query.append("messages.id IN (SELECT docid FROM messages_fulltext WHERE fulltext MATCH ?)"); - selectionArgs.add(fulltextQueryString); - break; - } - default: { - appendCondition(condition, query, selectionArgs); + if (condition.field == SearchField.MESSAGE_CONTENTS) { + String fulltextQueryString = condition.value; + if (condition.attribute != Attribute.CONTAINS) { + Timber.e("message contents can only be matched!"); } + query.append("messages.id IN (SELECT docid FROM messages_fulltext WHERE fulltext MATCH ?)"); + selectionArgs.add(fulltextQueryString); + } else { + appendCondition(condition, query, selectionArgs); } } else { query.append("("); - buildWhereClauseInternal(account, node.mLeft, query, selectionArgs); + buildWhereClauseInternal(node.mLeft, query, selectionArgs); query.append(") "); query.append(node.mValue.name()); query.append(" ("); - buildWhereClauseInternal(account, node.mRight, query, selectionArgs); + buildWhereClauseInternal(node.mRight, query, selectionArgs); query.append(")"); } } @@ -170,9 +130,8 @@ public class SqlQueryBuilder { columnName = "threads.root"; break; } - case MESSAGE_CONTENTS: - case SEARCHABLE: { - // Special cases handled in buildWhereClauseInternal() + case MESSAGE_CONTENTS: { + // Special case handled in buildWhereClauseInternal() break; } } diff --git a/app/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java b/app/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java index 1239d581f6ce9225d4f714270e9d1d54d18e9d3a..3dbb7d380f26aedfe1e67b2fc4b98a49ef45a0ab 100644 --- a/app/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java +++ b/app/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java @@ -34,8 +34,6 @@ import com.fsck.k9.mailstore.SpecialLocalFoldersCreator; import com.fsck.k9.notification.NotificationController; import com.fsck.k9.notification.NotificationStrategy; import com.fsck.k9.preferences.Protocols; -import com.fsck.k9.search.SearchAccount; -import org.jetbrains.annotations.NotNull; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -108,18 +106,6 @@ public class MessagingControllerTest extends K9RobolectricTest { private LocalMessage localMessageToSend1; private volatile boolean hasFetchedMessage = false; - private MessageCountsProvider messageCountsProvider = new MessageCountsProvider() { - @Override - public MessageCounts getMessageCounts(@NotNull SearchAccount searchAccount) { - return new MessageCounts(0, 0); - } - - @Override - public MessageCounts getMessageCounts(@NotNull Account account) { - return new MessageCounts(0, 0); - } - }; - private Preferences preferences; private String accountUuid; @@ -133,7 +119,7 @@ public class MessagingControllerTest extends K9RobolectricTest { preferences = Preferences.getPreferences(); controller = new MessagingController(appContext, notificationController, notificationStrategy, - localStoreProvider, messageCountsProvider, backendManager, preferences, messageStoreManager, + localStoreProvider, backendManager, preferences, messageStoreManager, saveMessageDataCreator, specialLocalFoldersCreator, Collections.emptyList()); configureAccount(); diff --git a/app/core/src/test/java/com/fsck/k9/helper/RealAddressFormatterTest.kt b/app/core/src/test/java/com/fsck/k9/helper/RealAddressFormatterTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..726ee33e6a57a579e787793a31a4a76bbeecdd1a --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/helper/RealAddressFormatterTest.kt @@ -0,0 +1,206 @@ +package com.fsck.k9.helper + +import android.graphics.Color +import android.text.Spannable +import android.text.style.ForegroundColorSpan +import androidx.core.text.getSpans +import com.fsck.k9.Account +import com.fsck.k9.Identity +import com.fsck.k9.RobolectricTest +import com.fsck.k9.mail.Address +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +private const val IDENTITY_ADDRESS = "me@domain.example" +private const val ME_TEXT = "me" + +class RealAddressFormatterTest : RobolectricTest() { + private val contactNameProvider = object : ContactNameProvider { + override fun getNameForAddress(address: String): String? { + return when (address) { + "user1@domain.example" -> "Contact One" + "spoof@domain.example" -> "contact@important.example" + else -> null + } + } + } + + private val account = Account("uuid").apply { + identities += Identity(email = IDENTITY_ADDRESS) + } + + @Test + fun `single identity`() { + val addressFormatter = createAddressFormatter() + + val displayName = addressFormatter.getDisplayName(Address(IDENTITY_ADDRESS, "irrelevant")) + + assertThat(displayName).isEqualTo(ME_TEXT) + } + + @Test + fun `multiple identities`() { + val account = Account("uuid").apply { + identities += Identity(description = "My identity", email = IDENTITY_ADDRESS) + identities += Identity(email = "another.one@domain.example") + } + val addressFormatter = createAddressFormatter(account) + + val displayName = addressFormatter.getDisplayName(Address(IDENTITY_ADDRESS, "irrelevant")) + + assertThat(displayName).isEqualTo("My identity") + } + + @Test + fun `identity without a description`() { + val account = Account("uuid").apply { + identities += Identity(name = "My name", email = IDENTITY_ADDRESS) + identities += Identity(email = "another.one@domain.example") + } + val addressFormatter = createAddressFormatter(account) + + val displayName = addressFormatter.getDisplayName(Address(IDENTITY_ADDRESS, "irrelevant")) + + assertThat(displayName).isEqualTo("My name") + } + + @Test + fun `identity without a description and name`() { + val account = Account("uuid").apply { + identities += Identity(email = IDENTITY_ADDRESS) + identities += Identity(email = "another.one@domain.example") + } + val addressFormatter = createAddressFormatter(account) + + val displayName = addressFormatter.getDisplayName(Address(IDENTITY_ADDRESS, "irrelevant")) + + assertThat(displayName).isEqualTo(IDENTITY_ADDRESS) + } + + @Test + fun `email address without display name`() { + val addressFormatter = createAddressFormatter() + + val displayName = addressFormatter.getDisplayName(Address("alice@domain.example")) + + assertThat(displayName).isEqualTo("alice@domain.example") + } + + @Test + fun `email address with display name`() { + val addressFormatter = createAddressFormatter() + + val displayName = addressFormatter.getDisplayName(Address("alice@domain.example", "Alice")) + + assertThat(displayName).isEqualTo("Alice") + } + + @Test + fun `don't look up contact when showContactNames = false`() { + val addressFormatter = createAddressFormatter(showContactNames = false) + + val displayName = addressFormatter.getDisplayName(Address("user1@domain.example", "User 1")) + + assertThat(displayName).isEqualTo("User 1") + } + + @Test + fun `contact lookup`() { + val addressFormatter = createAddressFormatter() + + val displayName = addressFormatter.getDisplayName(Address("user1@domain.example")) + + assertThat(displayName).isEqualTo("Contact One") + } + + @Test + fun `contact lookup despite display name`() { + val addressFormatter = createAddressFormatter() + + val displayName = addressFormatter.getDisplayName(Address("user1@domain.example", "User 1")) + + assertThat(displayName).isEqualTo("Contact One") + } + + @Test + fun `colored contact name`() { + val addressFormatter = createAddressFormatter(contactNameColor = Color.RED) + + val displayName = addressFormatter.getDisplayName(Address("user1@domain.example")) + + assertThat(displayName.toString()).isEqualTo("Contact One") + assertThat(displayName).isInstanceOf(Spannable::class.java) + val spans = (displayName as Spannable).getSpans(0, displayName.length) + assertThat(spans.map { it.foregroundColor }).containsExactly(Color.RED) + } + + @Test + fun `email address with display name but not showing correspondent names`() { + val addressFormatter = createAddressFormatter(showCorrespondentNames = false) + + val displayName = addressFormatter.getDisplayName(Address("alice@domain.example", "Alice")) + + assertThat(displayName).isEqualTo("alice@domain.example") + } + + @Test + fun `do not show display name that looks like an email address`() { + val addressFormatter = createAddressFormatter() + + val displayName = addressFormatter.getDisplayName(Address("mallory@domain.example", "potus@whitehouse.gov")) + + assertThat(displayName).isEqualTo("mallory@domain.example") + } + + @Test + fun `do show display name that contains an @ preceded by an opening parenthesis`() { + val addressFormatter = createAddressFormatter() + + val displayName = addressFormatter.getDisplayName(Address("gitlab@gitlab.example", "username (@username)")) + + assertThat(displayName).isEqualTo("username (@username)") + } + + @Test + fun `do show display name that starts with an @`() { + val addressFormatter = createAddressFormatter() + + val displayName = addressFormatter.getDisplayName(Address("address@domain.example", "@username")) + + assertThat(displayName).isEqualTo("@username") + } + + @Test + fun `spoof prevention doesn't apply to contact names`() { + val addressFormatter = createAddressFormatter() + + val displayName = addressFormatter.getDisplayName(Address("spoof@domain.example", "contact@important.example")) + + assertThat(displayName).isEqualTo("contact@important.example") + } + + @Test + fun `display name matches me text`() { + val addressFormatter = createAddressFormatter() + + val displayName = addressFormatter.getDisplayName(Address("someone_named_me@domain.example", "ME")) + + assertThat(displayName).isEqualTo("someone_named_me@domain.example") + } + + private fun createAddressFormatter( + account: Account = this.account, + showCorrespondentNames: Boolean = true, + showContactNames: Boolean = true, + contactNameColor: Int? = null + ): RealAddressFormatter { + return RealAddressFormatter( + contactNameProvider = contactNameProvider, + account = account, + showCorrespondentNames = showCorrespondentNames, + showContactNames = showContactNames, + contactNameColor = contactNameColor, + meText = ME_TEXT + ) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/message/ReplyActionStrategyTest.kt b/app/core/src/test/java/com/fsck/k9/message/ReplyActionStrategyTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..e366859072eb8b6757c67681190fbe804e4185bd --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/ReplyActionStrategyTest.kt @@ -0,0 +1,111 @@ +package com.fsck.k9.message + +import com.fsck.k9.Account +import com.fsck.k9.Identity +import com.fsck.k9.helper.ReplyToParser +import com.fsck.k9.mail.buildMessage +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +private const val IDENTITY_EMAIL_ADDRESS = "myself@domain.example" + +class ReplyActionStrategyTest { + private val account = createAccount() + private val replyActionStrategy = ReplyActionStrategy(ReplyToParser()) + + @Test + fun `message sent to only our identity`() { + val message = buildMessage { + header("From", "sender@domain.example") + header("To", IDENTITY_EMAIL_ADDRESS) + } + + val replyActions = replyActionStrategy.getReplyActions(account, message) + + assertThat(replyActions.defaultAction).isEqualTo(ReplyAction.REPLY) + assertThat(replyActions.additionalActions).isEmpty() + } + + @Test + fun `message sent to our identity and others`() { + val message = buildMessage { + header("From", "sender@domain.example") + header("To", "$IDENTITY_EMAIL_ADDRESS, other@domain.example") + } + + val replyActions = replyActionStrategy.getReplyActions(account, message) + + assertThat(replyActions.defaultAction).isEqualTo(ReplyAction.REPLY_ALL) + assertThat(replyActions.additionalActions).containsExactly(ReplyAction.REPLY) + } + + @Test + fun `message sent to our identity and others (CC)`() { + val message = buildMessage { + header("From", "sender@domain.example") + header("Cc", "$IDENTITY_EMAIL_ADDRESS, other@domain.example") + } + + val replyActions = replyActionStrategy.getReplyActions(account, message) + + assertThat(replyActions.defaultAction).isEqualTo(ReplyAction.REPLY_ALL) + assertThat(replyActions.additionalActions).containsExactly(ReplyAction.REPLY) + } + + @Test + fun `message sent to our identity and others (To+CC)`() { + val message = buildMessage { + header("From", "sender@domain.example") + header("To", IDENTITY_EMAIL_ADDRESS) + header("Cc", "other@domain.example") + } + + val replyActions = replyActionStrategy.getReplyActions(account, message) + + assertThat(replyActions.defaultAction).isEqualTo(ReplyAction.REPLY_ALL) + assertThat(replyActions.additionalActions).containsExactly(ReplyAction.REPLY) + } + + @Test + fun `message sent to our identity and others (CC+To)`() { + val message = buildMessage { + header("From", "sender@domain.example") + header("To", "other@domain.example") + header("Cc", IDENTITY_EMAIL_ADDRESS) + } + + val replyActions = replyActionStrategy.getReplyActions(account, message) + + assertThat(replyActions.defaultAction).isEqualTo(ReplyAction.REPLY_ALL) + assertThat(replyActions.additionalActions).containsExactly(ReplyAction.REPLY) + } + + @Test + fun `message where neither sender nor recipient addresses belong to account`() { + val message = buildMessage { + header("From", "sender@domain.example") + header("To", "recipient@domain.example") + } + + val replyActions = replyActionStrategy.getReplyActions(account, message) + + assertThat(replyActions.defaultAction).isEqualTo(ReplyAction.REPLY_ALL) + assertThat(replyActions.additionalActions).containsExactly(ReplyAction.REPLY) + } + + @Test + fun `message without any sender or recipient headers`() { + val message = buildMessage {} + + val replyActions = replyActionStrategy.getReplyActions(account, message) + + assertThat(replyActions.defaultAction).isNull() + assertThat(replyActions.additionalActions).isEmpty() + } + + private fun createAccount(): Account { + return Account("00000000-0000-4000-0000-000000000000").apply { + identities += Identity(name = "Myself", email = IDENTITY_EMAIL_ADDRESS) + } + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/TestNotificationResourceProvider.kt b/app/core/src/test/java/com/fsck/k9/notification/TestNotificationResourceProvider.kt index ec8e9b59d92a72ee01d273701b444a835bc2f119..b347bc7a4676bfeac40a63d1d17c3fb085659f61 100644 --- a/app/core/src/test/java/com/fsck/k9/notification/TestNotificationResourceProvider.kt +++ b/app/core/src/test/java/com/fsck/k9/notification/TestNotificationResourceProvider.kt @@ -41,11 +41,6 @@ class TestNotificationResourceProvider : NotificationResourceProvider { override fun certificateErrorBody(): String = "Check your server settings" - override fun newMailTitle(): String = "New mail" - - override fun newMailUnreadMessageCount(unreadMessageCount: Int, accountName: String): String = - "$unreadMessageCount Unread ($accountName)" - override fun newMessagesTitle(newMessagesCount: Int): String = when (newMessagesCount) { 1 -> "1 new message" else -> "$newMessagesCount new messages" diff --git a/app/k9mail-jmap/src/main/res/values/themes.xml b/app/k9mail-jmap/src/main/res/values/themes.xml index 4afbec42457f45f9b20ba868b2cd83e222465e0c..7d7acbce12f18387888e4b062dae67898885de10 100644 --- a/app/k9mail-jmap/src/main/res/values/themes.xml +++ b/app/k9mail-jmap/src/main/res/values/themes.xml @@ -21,6 +21,8 @@ @color/material_pink_500 @color/material_pink_300 #ffffff + @color/material_gray_200 + @color/material_gray_100 @color/material_gray_50 ?attr/colorAccent ?android:attr/colorBackground @@ -132,7 +134,8 @@ @drawable/btn_check_star #FFC300 #ffffffff - @drawable/ic_person_plus + @drawable/ic_person_add + #ffcccccc #e8e8e8 #ffababab @array/contact_picture_fallback_background_colors_light @@ -182,6 +185,8 @@ @color/material_gray_50 @color/material_pink_300 @color/material_pink_500 + @color/material_gray_900 + @color/material_gray_900 @color/material_gray_900 ?attr/colorAccent ?android:attr/colorBackground @@ -293,7 +298,8 @@ @drawable/btn_check_star #FFC300 #000000 - @drawable/ic_person_plus + @drawable/ic_person_add + #ff555555 #313131 #313131 #ff606060 diff --git a/app/k9mail/build.gradle b/app/k9mail/build.gradle index 3d49cf82eba1eae30569cfcc9f0a3abef5041c8f..14eec5368f93c350f355e8602fe5755864ac641a 100644 --- a/app/k9mail/build.gradle +++ b/app/k9mail/build.gradle @@ -56,8 +56,8 @@ android { applicationId "foundation.e.mail" testApplicationId "foundation.e.mail.tests" - versionCode 35004 - versionName '6.504' + versionCode 35006 + versionName '6.506' // Keep in sync with the resource string array 'supported_languages' resConfigs "in", "br", "ca", "cs", "cy", "da", "de", "et", "en", "en_GB", "es", "eo", "eu", "fr", "gd", "gl", diff --git a/app/k9mail/proguard-rules.pro b/app/k9mail/proguard-rules.pro index dbde0f80d5d160221dc7a18d8434c85cd1204bdf..5c9615c774104801ddfcc9e1d0c1a4d8facac019 100644 --- a/app/k9mail/proguard-rules.pro +++ b/app/k9mail/proguard-rules.pro @@ -29,6 +29,10 @@ -dontnote com.fsck.k9.ui.messageview.** -dontnote com.fsck.k9.view.** +-assumevalues class * extends android.view.View { + boolean isInEditMode() return false; +} + -keep public class org.openintents.openpgp.** -keepclassmembers class * extends androidx.appcompat.widget.SearchView { diff --git a/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationResourceProvider.kt b/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationResourceProvider.kt index aac16b241eafef9486f494f074486e1fde7727a3..917d492c3d9690521dfce64db08e40aec89650ab 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationResourceProvider.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationResourceProvider.kt @@ -47,11 +47,6 @@ class K9NotificationResourceProvider(private val context: Context) : Notificatio override fun certificateErrorBody(): String = context.getString(R.string.notification_certificate_error_text) - override fun newMailTitle(): String = context.getString(R.string.notification_new_title) - - override fun newMailUnreadMessageCount(unreadMessageCount: Int, accountName: String): String = - context.getString(R.string.notification_new_one_account_fmt, unreadMessageCount, accountName) - override fun newMessagesTitle(newMessagesCount: Int): String = context.resources.getQuantityString( R.plurals.notification_new_messages_title, diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/unread/KoinModule.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/KoinModule.kt index 1cae6a4fde663232db1c709eca35535b489733c2..1e139d9c5b4c40cdaa3ef1f5a587efd373c2d202 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/widget/unread/KoinModule.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/KoinModule.kt @@ -8,7 +8,7 @@ val unreadWidgetModule = module { UnreadWidgetDataProvider( context = get(), preferences = get(), - messagingController = get(), + messageCountsProvider = get(), defaultFolderProvider = get(), folderRepository = get(), folderNameFormatterFactory = get() diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetDataProvider.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetDataProvider.kt index 78df3d9cf5ca153bea241eb9265f62ea341eadf6..15d6a93d84385ee147f594aa2e5f657bdba4c32b 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetDataProvider.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetDataProvider.kt @@ -5,7 +5,7 @@ import android.content.Intent import com.fsck.k9.Account import com.fsck.k9.Preferences import com.fsck.k9.activity.MessageList -import com.fsck.k9.controller.MessagingController +import com.fsck.k9.controller.MessageCountsProvider import com.fsck.k9.mailstore.FolderRepository import com.fsck.k9.search.LocalSearch import com.fsck.k9.search.SearchAccount @@ -17,13 +17,12 @@ import com.fsck.k9.ui.R as UiR class UnreadWidgetDataProvider( private val context: Context, private val preferences: Preferences, - private val messagingController: MessagingController, + private val messageCountsProvider: MessageCountsProvider, private val defaultFolderProvider: DefaultFolderProvider, private val folderRepository: FolderRepository, private val folderNameFormatterFactory: FolderNameFormatterFactory ) { fun loadUnreadWidgetData(configuration: UnreadWidgetConfiguration): UnreadWidgetData? = with(configuration) { - @Suppress("CascadeIf") if (SearchAccount.UNIFIED_INBOX == accountUuid) { loadSearchAccountData(configuration) } else if (folderId != null) { @@ -36,7 +35,7 @@ class UnreadWidgetDataProvider( private fun loadSearchAccountData(configuration: UnreadWidgetConfiguration): UnreadWidgetData { val searchAccount = getSearchAccount(configuration.accountUuid) val title = searchAccount.name - val unreadCount = messagingController.getUnreadMessageCount(searchAccount) + val unreadCount = messageCountsProvider.getMessageCounts(searchAccount).unread val clickIntent = MessageList.intentDisplaySearch(context, searchAccount.relatedSearch, false, true, true) return UnreadWidgetData(configuration, title, unreadCount, clickIntent) @@ -50,7 +49,7 @@ class UnreadWidgetDataProvider( private fun loadAccountData(configuration: UnreadWidgetConfiguration): UnreadWidgetData? { val account = preferences.getAccount(configuration.accountUuid) ?: return null val title = account.displayName - val unreadCount = messagingController.getUnreadMessageCount(account) + val unreadCount = messageCountsProvider.getMessageCounts(account).unread val clickIntent = getClickIntentForAccount(account) return UnreadWidgetData(configuration, title, unreadCount, clickIntent) @@ -70,7 +69,7 @@ class UnreadWidgetDataProvider( val folderDisplayName = getFolderDisplayName(account, folderId) val title = context.getString(UiR.string.unread_widget_title, accountName, folderDisplayName) - val unreadCount = messagingController.getFolderUnreadMessageCount(account, folderId) + val unreadCount = messageCountsProvider.getUnreadMessageCount(account, folderId) val clickIntent = getClickIntentForFolder(account, folderId) diff --git a/app/k9mail/src/main/res/values/themes.xml b/app/k9mail/src/main/res/values/themes.xml index 98833ebb6b18afe1095eb7812606df801f377290..8fa72a1813aee65368c02e0acb974b975574a60a 100644 --- a/app/k9mail/src/main/res/values/themes.xml +++ b/app/k9mail/src/main/res/values/themes.xml @@ -41,6 +41,7 @@ @color/color_default_ternary_text @drawable/edittext_cursor @style/Theme.k9.Dialog.Base + @style/CustomBottomSheetDialogTheme @style/PreferenceThemeOverlay @drawable/ic_hamburger @@ -121,7 +122,6 @@ @drawable/ic_messagelist_answered_forwarded @drawable/btn_check_star @color/color_default_background - @drawable/ic_person_plus @color/color_default_foreground #ffababab @array/contact_picture_fallback_background_colors_light @@ -271,7 +271,6 @@ @drawable/ic_messagelist_answered_forwarded @drawable/btn_check_star @color/color_default_background - @drawable/ic_person_plus @color/color_default_foreground #ffababab @color/color_contact_token_background diff --git a/app/k9mail/src/test/java/com/fsck/k9/widget/unread/UnreadWidgetDataProviderTest.kt b/app/k9mail/src/test/java/com/fsck/k9/widget/unread/UnreadWidgetDataProviderTest.kt index 6e782f8a8063b830e0ab339e7b9b9238ad8421a1..32ba78cfaaddf6f4b005c6e9b97e51cbe23aa085 100644 --- a/app/k9mail/src/test/java/com/fsck/k9/widget/unread/UnreadWidgetDataProviderTest.kt +++ b/app/k9mail/src/test/java/com/fsck/k9/widget/unread/UnreadWidgetDataProviderTest.kt @@ -4,7 +4,8 @@ import android.content.Context import com.fsck.k9.Account import com.fsck.k9.AppRobolectricTest import com.fsck.k9.Preferences -import com.fsck.k9.controller.MessagingController +import com.fsck.k9.controller.MessageCounts +import com.fsck.k9.controller.MessageCountsProvider import com.fsck.k9.mailstore.Folder import com.fsck.k9.mailstore.FolderRepository import com.fsck.k9.mailstore.FolderType @@ -14,22 +15,21 @@ import com.fsck.k9.ui.folders.FolderNameFormatterFactory import com.fsck.k9.ui.messagelist.DefaultFolderProvider import com.google.common.truth.Truth.assertThat import org.junit.Test -import org.mockito.ArgumentMatchers.eq import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.robolectric.RuntimeEnvironment class UnreadWidgetDataProviderTest : AppRobolectricTest() { - val context: Context = RuntimeEnvironment.getApplication() - val account = createAccount() - val preferences = createPreferences() - val messagingController = createMessagingController() - val defaultFolderStrategy = createDefaultFolderStrategy() - val folderRepository = createFolderRepository() - val folderNameFormatterFactory = createFolderNameFormatterFactory() - val provider = UnreadWidgetDataProvider( - context, preferences, messagingController, defaultFolderStrategy, + private val context: Context = RuntimeEnvironment.getApplication() + private val account = createAccount() + private val preferences = createPreferences() + private val messageCountsProvider = createMessageCountsProvider() + private val defaultFolderStrategy = createDefaultFolderStrategy() + private val folderRepository = createFolderRepository() + private val folderNameFormatterFactory = createFolderNameFormatterFactory() + private val provider = UnreadWidgetDataProvider( + context, preferences, messageCountsProvider, defaultFolderStrategy, folderRepository, folderNameFormatterFactory ) @@ -82,26 +82,34 @@ class UnreadWidgetDataProviderTest : AppRobolectricTest() { assertThat(widgetData).isNull() } - fun createAccount(): Account = mock { + private fun createAccount(): Account = mock { on { uuid } doReturn ACCOUNT_UUID on { displayName } doReturn ACCOUNT_NAME } - fun createPreferences(): Preferences = mock { + private fun createPreferences(): Preferences = mock { on { getAccount(ACCOUNT_UUID) } doReturn account } - fun createMessagingController(): MessagingController = mock { - on { getUnreadMessageCount(any()) } doReturn SEARCH_ACCOUNT_UNREAD_COUNT - on { getUnreadMessageCount(account) } doReturn ACCOUNT_UNREAD_COUNT - on { getFolderUnreadMessageCount(eq(account), eq(FOLDER_ID)) } doReturn FOLDER_UNREAD_COUNT + private fun createMessageCountsProvider() = object : MessageCountsProvider { + override fun getMessageCounts(account: Account): MessageCounts { + return MessageCounts(unread = ACCOUNT_UNREAD_COUNT, starred = 0) + } + + override fun getMessageCounts(searchAccount: SearchAccount): MessageCounts { + return MessageCounts(unread = SEARCH_ACCOUNT_UNREAD_COUNT, starred = 0) + } + + override fun getUnreadMessageCount(account: Account, folderId: Long): Int { + return FOLDER_UNREAD_COUNT + } } - fun createDefaultFolderStrategy(): DefaultFolderProvider = mock { + private fun createDefaultFolderStrategy(): DefaultFolderProvider = mock { on { getDefaultFolder(account) } doReturn FOLDER_ID } - fun createFolderRepository(): FolderRepository { + private fun createFolderRepository(): FolderRepository { return mock { on { getFolder(account, FOLDER_ID) } doReturn FOLDER } 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 ca1e7dec6abd46d439b20e4d49f035f25e415afb..638d4771f237ef6c80a965e155bfc1944c2179a1 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 @@ -15,6 +15,7 @@ import com.fsck.k9.mailstore.MoreMessages import com.fsck.k9.mailstore.SaveMessageData import com.fsck.k9.mailstore.StorageManager import com.fsck.k9.message.extractors.BasicPartInfoExtractor +import com.fsck.k9.search.ConditionsTreeNode import java.util.Date class K9MessageStore( @@ -132,6 +133,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) } @@ -176,6 +181,18 @@ class K9MessageStore( return retrieveFolderOperations.getMessageCount(folderId) } + override fun getUnreadMessageCount(folderId: Long): Int { + return retrieveFolderOperations.getUnreadMessageCount(folderId) + } + + override fun getUnreadMessageCount(conditions: ConditionsTreeNode): Int { + return retrieveFolderOperations.getUnreadMessageCount(conditions) + } + + override fun getStarredMessageCount(conditions: ConditionsTreeNode): Int { + return retrieveFolderOperations.getStarredMessageCount(conditions) + } + override fun getSize(): Long { return databaseOperations.getSize() } @@ -224,6 +241,10 @@ class K9MessageStore( updateFolderOperations.setStatus(folderId, status) } + override fun setVisibleLimit(folderId: Long, visibleLimit: Int) { + updateFolderOperations.setVisibleLimit(folderId, visibleLimit) + } + override fun deleteFolders(folderServerIds: List) { deleteFolderOperations.deleteFolders(folderServerIds) } diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveFolderOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveFolderOperations.kt index ea04aa6b97caac9bd1b674f01cc7ae2c3104c545..cd18b675adba234fc73f0c01fcd4094b6087700a 100644 --- a/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveFolderOperations.kt +++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveFolderOperations.kt @@ -12,6 +12,8 @@ import com.fsck.k9.mailstore.FolderNotFoundException import com.fsck.k9.mailstore.LockableDatabase import com.fsck.k9.mailstore.MoreMessages import com.fsck.k9.mailstore.toFolderType +import com.fsck.k9.search.ConditionsTreeNode +import com.fsck.k9.search.SqlQueryBuilder internal class RetrieveFolderOperations(private val lockableDatabase: LockableDatabase) { fun getFolder(folderId: Long, mapper: FolderMapper): T? { @@ -159,6 +161,48 @@ $displayModeSelection } } + fun getUnreadMessageCount(folderId: Long): Int { + return lockableDatabase.execute(false) { db -> + db.rawQuery( + "SELECT COUNT(id) FROM messages WHERE empty = 0 AND deleted = 0 AND read = 0 AND folder_id = ?", + arrayOf(folderId.toString()) + ).use { cursor -> + if (cursor.moveToFirst()) cursor.getInt(0) else 0 + } + } + } + + fun getUnreadMessageCount(conditions: ConditionsTreeNode): Int { + return getMessageCount(condition = "messages.read = 0", conditions) + } + + fun getStarredMessageCount(conditions: ConditionsTreeNode): Int { + return getMessageCount(condition = "messages.flagged = 1", conditions) + } + + private fun getMessageCount(condition: String, extraConditions: ConditionsTreeNode): Int { + val whereBuilder = StringBuilder() + val queryArgs = mutableListOf() + SqlQueryBuilder.buildWhereClause(extraConditions, whereBuilder, queryArgs) + + val where = if (whereBuilder.isNotEmpty()) "AND ($whereBuilder)" else "" + val selectionArgs = queryArgs.toTypedArray() + + val query = + """ +SELECT COUNT(messages.id) +FROM messages +JOIN folders ON (folders.id = messages.folder_id) +WHERE (messages.empty = 0 AND messages.deleted = 0 AND $condition) $where + """ + + return lockableDatabase.execute(false) { db -> + db.rawQuery(query, selectionArgs).use { cursor -> + if (cursor.moveToFirst()) cursor.getInt(0) else 0 + } + } + } + fun hasMoreMessages(folderId: Long): MoreMessages { return getFolder(folderId) { it.moreMessages } ?: throw FolderNotFoundException(folderId) } 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 c094ae3b3c258a6d04c7eca4b385ae1eb06d6bf3..041dda747450aa7644d4a17148fa2a9d359c7d1b 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/main/java/com/fsck/k9/storage/messages/UpdateFolderOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/UpdateFolderOperations.kt index 06486aa2afe0aa7ab35f406d90c80d99607cd1b2..5d42aae664f6ad3edb6903ce3c4578dd8b1cc920 100644 --- a/app/storage/src/main/java/com/fsck/k9/storage/messages/UpdateFolderOperations.kt +++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/UpdateFolderOperations.kt @@ -79,6 +79,16 @@ internal class UpdateFolderOperations(private val lockableDatabase: LockableData setString(folderId = folderId, columnName = "status", value = status) } + fun setVisibleLimit(folderId: Long, visibleLimit: Int) { + lockableDatabase.execute(false) { db -> + val contentValues = ContentValues().apply { + put("visible_limit", visibleLimit) + } + + db.update("folders", contentValues, "id = ?", arrayOf(folderId.toString())) + } + } + private fun setString(folderId: Long, columnName: String, value: String?) { lockableDatabase.execute(false) { db -> val contentValues = ContentValues().apply { diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveFolderOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveFolderOperationsTest.kt index 234dc350ca5dd787d7e517159d662ca8d08d0b57..4b4c6ed8e7e802cfeea4cd420dcf51504f2665fc 100644 --- a/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveFolderOperationsTest.kt +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveFolderOperationsTest.kt @@ -6,6 +6,8 @@ import com.fsck.k9.mail.FolderType import com.fsck.k9.mailstore.FolderNotFoundException import com.fsck.k9.mailstore.MoreMessages import com.fsck.k9.mailstore.toDatabaseFolderType +import com.fsck.k9.search.LocalSearch +import com.fsck.k9.search.SearchSpecification import com.fsck.k9.storage.RobolectricTest import com.google.common.truth.Truth.assertThat import org.junit.Assert.fail @@ -351,6 +353,96 @@ class RetrieveFolderOperationsTest : RobolectricTest() { assertThat(result).isEqualTo(2) } + @Test + fun `get unread message count from empty folder`() { + val folderId = sqliteDatabase.createFolder() + + val result = retrieveFolderOperations.getUnreadMessageCount(folderId) + + assertThat(result).isEqualTo(0) + } + + @Test + fun `get unread message count from non-existent folder`() { + val result = retrieveFolderOperations.getUnreadMessageCount(23) + + assertThat(result).isEqualTo(0) + } + + @Test + fun `get unread message count from non-empty folder`() { + val folderId = sqliteDatabase.createFolder() + sqliteDatabase.createMessage(folderId = folderId, read = false) + sqliteDatabase.createMessage(folderId = folderId, read = false) + sqliteDatabase.createMessage(folderId = folderId, read = true) + + val result = retrieveFolderOperations.getUnreadMessageCount(folderId) + + assertThat(result).isEqualTo(2) + } + + @Test + fun `get unread message count with condition from empty folder`() { + sqliteDatabase.createFolder(integrate = true) + + val result = retrieveFolderOperations.getUnreadMessageCount(unifiedInboxConditions) + + assertThat(result).isEqualTo(0) + } + + @Test + fun `get unread message count with condition from non-existent folder`() { + val result = retrieveFolderOperations.getUnreadMessageCount(unifiedInboxConditions) + + assertThat(result).isEqualTo(0) + } + + @Test + fun `get unread message count with condition from non-empty folder`() { + val folderId1 = sqliteDatabase.createFolder(integrate = true) + sqliteDatabase.createMessage(folderId = folderId1, read = false) + sqliteDatabase.createMessage(folderId = folderId1, read = false) + sqliteDatabase.createMessage(folderId = folderId1, read = true) + val folderId2 = sqliteDatabase.createFolder(integrate = true) + sqliteDatabase.createMessage(folderId = folderId2, read = false) + sqliteDatabase.createMessage(folderId = folderId2, read = true) + + val result = retrieveFolderOperations.getUnreadMessageCount(unifiedInboxConditions) + + assertThat(result).isEqualTo(3) + } + + @Test + fun `get starred message count with condition from empty folder`() { + sqliteDatabase.createFolder(integrate = true) + + val result = retrieveFolderOperations.getStarredMessageCount(unifiedInboxConditions) + + assertThat(result).isEqualTo(0) + } + + @Test + fun `get starred message count with condition from non-existent folder`() { + val result = retrieveFolderOperations.getStarredMessageCount(unifiedInboxConditions) + + assertThat(result).isEqualTo(0) + } + + @Test + fun `get starred message count with condition from non-empty folder`() { + val folderId1 = sqliteDatabase.createFolder(integrate = true) + sqliteDatabase.createMessage(folderId = folderId1, flagged = false) + sqliteDatabase.createMessage(folderId = folderId1, flagged = true) + val folderId2 = sqliteDatabase.createFolder(integrate = true) + sqliteDatabase.createMessage(folderId = folderId2, flagged = true) + sqliteDatabase.createMessage(folderId = folderId2, flagged = true) + sqliteDatabase.createMessage(folderId = folderId2, flagged = false) + + val result = retrieveFolderOperations.getStarredMessageCount(unifiedInboxConditions) + + assertThat(result).isEqualTo(3) + } + @Test fun `get 'more messages' value from non-existent folder`() { try { @@ -387,4 +479,8 @@ class RetrieveFolderOperationsTest : RobolectricTest() { assertThat(result).isEqualTo(MoreMessages.TRUE) } + + private val unifiedInboxConditions = LocalSearch().apply { + and(SearchSpecification.SearchField.INTEGRATE, "1", SearchSpecification.Attribute.EQUALS) + }.conditions } 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 09dccb31d7032c249727019e36f97e3db0313647..09246c3cb30c5a34ea77e9ab6f3e6230b88d90ec 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/storage/src/test/java/com/fsck/k9/storage/messages/UpdateFolderOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateFolderOperationsTest.kt index 83bc4fe5f9e23ef90b77bcff32086b839ffa1771..0f6885977ded09501252c0545c95b17396e1cb2c 100644 --- a/app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateFolderOperationsTest.kt +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateFolderOperationsTest.kt @@ -152,4 +152,15 @@ class UpdateFolderOperationsTest : RobolectricTest() { assertThat(folder.id).isEqualTo(folderId) assertThat(folder.status).isEqualTo("Sync error") } + + @Test + fun `update visible limit`() { + val folderId = sqliteDatabase.createFolder(visibleLimit = 10) + + updateFolderOperations.setVisibleLimit(folderId = folderId, visibleLimit = 25) + + val folder = sqliteDatabase.readFolders().first() + assertThat(folder.id).isEqualTo(folderId) + assertThat(folder.visibleLimit).isEqualTo(25) + } } diff --git a/app/ui/base/src/main/res/values/styles.xml b/app/ui/base/src/main/res/values/styles.xml index 3136371b50642b74fc129b317d13e821b04b42fa..32cfef3d6e4a4966dcc11cb90ec716821a3615e0 100644 --- a/app/ui/base/src/main/res/values/styles.xml +++ b/app/ui/base/src/main/res/values/styles.xml @@ -29,4 +29,9 @@ @null - \ No newline at end of file + + diff --git a/app/ui/legacy/build.gradle b/app/ui/legacy/build.gradle index e7141371789c203da10366d7d1a8a0ca6283bbd6..2e4f957a751dc694f73182a050c3db2b26f0ea6f 100644 --- a/app/ui/legacy/build.gradle +++ b/app/ui/legacy/build.gradle @@ -11,6 +11,7 @@ dependencies { implementation project(":app:autodiscovery:api") implementation project(":app:autodiscovery:providersxml") implementation project(":mail:common") + implementation project(":ui-utils:ToolbarBottomSheet") //TODO: Remove AccountSetupIncoming's dependency on these compileOnly project(":mail:protocols:imap") 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 79f928ec3635dc1d5c0014ee9593286e1a6cd597..9ae3e125bc60620b9847e08179c3e3d762def88d 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/activity/MessageList.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageList.kt index b86e9ab8a1936415564e1744bdd2f1ab3af1c343..fbaddd1ef9faeddf1dc18947d08737d537bb8bf8 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageList.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageList.kt @@ -159,6 +159,9 @@ open class MessageList : private var messageListWasDisplayed = false private var viewSwitcher: ViewSwitcher? = null + private val isShowAccountChip: Boolean + get() = messageListFragment?.isShowAccountChip ?: true + public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setLayout(R.layout.message_list_loading) @@ -1002,7 +1005,7 @@ open class MessageList : displayMode = DisplayMode.MESSAGE_LIST MessageActions.actionEditDraft(this, messageReference) } else { - val fragment = MessageViewContainerFragment.newInstance(messageReference) + val fragment = MessageViewContainerFragment.newInstance(messageReference, isShowAccountChip) supportFragmentManager.commitNow { replace(R.id.message_view_container, fragment, FRAGMENT_TAG_MESSAGE_VIEW_CONTAINER) } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageListActivityConfig.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageListActivityConfig.kt index bbfc0ff5f32d4e24d1bcd46efc4bf15c82604c22..ba099bca565481223e501baddff8227cea548614 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageListActivityConfig.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageListActivityConfig.kt @@ -27,10 +27,7 @@ data class MessageListActivityConfig( val fontSizeMessageListDate: Int, val fontSizeMessageListPreview: Int, val fontSizeMessageViewSender: Int, - val fontSizeMessageViewTo: Int, - val fontSizeMessageViewCC: Int, - val fontSizeMessageViewBCC: Int, - val fontSizeMessageViewAdditionalHeaders: Int, + val fontSizeMessageViewRecipients: Int, val fontSizeMessageViewSubject: Int, val fontSizeMessageViewDate: Int, val fontSizeMessageViewContentAsPercent: Int, @@ -62,10 +59,7 @@ data class MessageListActivityConfig( fontSizeMessageListDate = K9.fontSizes.messageListDate, fontSizeMessageListPreview = K9.fontSizes.messageListPreview, fontSizeMessageViewSender = K9.fontSizes.messageViewSender, - fontSizeMessageViewTo = K9.fontSizes.messageViewTo, - fontSizeMessageViewCC = K9.fontSizes.messageViewCC, - fontSizeMessageViewBCC = K9.fontSizes.messageViewBCC, - fontSizeMessageViewAdditionalHeaders = K9.fontSizes.messageViewAdditionalHeaders, + fontSizeMessageViewRecipients = K9.fontSizes.messageViewRecipients, fontSizeMessageViewSubject = K9.fontSizes.messageViewSubject, fontSizeMessageViewDate = K9.fontSizes.messageViewDate, fontSizeMessageViewContentAsPercent = K9.fontSizes.messageViewContentAsPercent, diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/misc/NonConfigurationInstance.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/misc/NonConfigurationInstance.java deleted file mode 100644 index 0cbd0edf30c3c8c845b2c7478c80408f1c49a956..0000000000000000000000000000000000000000 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/misc/NonConfigurationInstance.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.fsck.k9.activity.misc; - -import android.app.Activity; - - -public interface NonConfigurationInstance { - /** - * Decide whether to retain this {@code NonConfigurationInstance} and clean up resources if - * necessary. - * - *

- * This needs to be called when the current activity is being destroyed during an activity - * restart due to a configuration change.
- * Implementations should make sure that references to the {@code Activity} instance that is - * about to be destroyed are cleared to avoid memory leaks. This includes all UI elements that - * are bound to an activity (e.g. dialogs). They can be re-created in - * {@link #restore(Activity)}. - *

- * - * @return {@code true} if this instance should be retained; {@code false} otherwise. - * - * @see Activity#onRetainNonConfigurationInstance() - */ - boolean retain(); - - /** - * Connect this retained {@code NonConfigurationInstance} to the new {@link Activity} instance - * after the activity was restarted due to a configuration change. - * - *

- * This also creates a new progress dialog that is bound to the new activity. - *

- * - * @param activity - * The new {@code Activity} instance. Never {@code null}. - */ - void restore(Activity activity); -} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/ContactBadge.java b/app/ui/legacy/src/main/java/com/fsck/k9/ui/ContactBadge.java index 1cac71e0bbc32c72cc9ab3d9b5084f0c91b47fe9..f3d92763d67c9ae173156577ae4c7fa98accd6be 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/ContactBadge.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/ContactBadge.java @@ -90,21 +90,6 @@ public class ContactBadge extends CircleImageView implements OnClickListener { onContactUriChanged(); } - /** - * Assign a contact based on an email address. This should only be used when - * the contact's URI is not available, as an extra query will have to be - * performed to lookup the URI based on the email. - * - * @param emailAddress - * The email address of the contact. - * @param lazyLookup - * If this is true, the lookup query will not be performed - * until this view is clicked. - */ - public void assignContactFromEmail(String emailAddress, boolean lazyLookup) { - assignContactFromEmail(emailAddress, lazyLookup, null); - } - /** * Assign a contact based on an email address. This should only be used when * the contact's URI is not available, as an extra query will have to be diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/folders/FolderIconProvider.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/folders/FolderIconProvider.kt index 0d4f98db1f00c7e23c157358ff76b523295eda6e..40625b9743caf886df84958e1c3c0793380427cd 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/folders/FolderIconProvider.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/folders/FolderIconProvider.kt @@ -4,7 +4,6 @@ import android.content.res.Resources import android.util.TypedValue import com.fsck.k9.mailstore.FolderType import com.fsck.k9.ui.R -import com.fsck.k9.mail.FolderType as LegacyFolderType class FolderIconProvider(private val theme: Resources.Theme) { private val iconFolderInboxResId: Int @@ -46,15 +45,4 @@ class FolderIconProvider(private val theme: Resources.Theme) { FolderType.SPAM -> iconFolderSpamResId else -> iconFolderResId } - - fun getFolderIcon(type: LegacyFolderType): Int = when (type) { - LegacyFolderType.INBOX -> iconFolderInboxResId - LegacyFolderType.OUTBOX -> iconFolderOutboxResId - LegacyFolderType.SENT -> iconFolderSentResId - LegacyFolderType.TRASH -> iconFolderTrashResId - LegacyFolderType.DRAFTS -> iconFolderDraftsResId - LegacyFolderType.ARCHIVE -> iconFolderArchiveResId - LegacyFolderType.SPAM -> iconFolderSpamResId - else -> iconFolderResId - } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/helper/BottomBaselineTextView.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/helper/BottomBaselineTextView.kt new file mode 100644 index 0000000000000000000000000000000000000000..8769cb805a1039b94dc46c1fad9d0111ad7cfdff --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/helper/BottomBaselineTextView.kt @@ -0,0 +1,21 @@ +package com.fsck.k9.ui.helper + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatTextView + +/** + * Return the baseline of the last line of text, instead of TextView's default of the first line. + */ +// Source: https://stackoverflow.com/a/62419876 +class BottomBaselineTextView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : AppCompatTextView(context, attrs) { + + override fun getBaseline(): Int { + val layout = layout ?: return super.getBaseline() + val baselineOffset = super.getBaseline() - layout.getLineBaseline(0) + return baselineOffset + layout.getLineBaseline(layout.lineCount - 1) + } +} 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 0000000000000000000000000000000000000000..399883c40c587fe0c0be8c0b4e9c7df2aefb6c7a --- /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 0000000000000000000000000000000000000000..eefb56e8e0091ea4b2318dcad07aaa67fb769327 --- /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/CryptoStatusItem.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/CryptoStatusItem.kt new file mode 100644 index 0000000000000000000000000000000000000000..5f4116e06d1c818ee01bcdaff809e6bd3ece61ef --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/CryptoStatusItem.kt @@ -0,0 +1,52 @@ +package com.fsck.k9.ui.messagedetails + +import android.content.res.ColorStateList +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import com.fsck.k9.ui.R +import com.fsck.k9.ui.resolveColorAttribute +import com.mikepenz.fastadapter.FastAdapter +import com.mikepenz.fastadapter.items.AbstractItem + +internal class CryptoStatusItem(val cryptoDetails: CryptoDetails) : AbstractItem() { + override val type = R.id.message_details_crypto_status + override val layoutRes = R.layout.message_details_crypto_status_item + + override fun getViewHolder(v: View) = ViewHolder(v) + + class ViewHolder(view: View) : FastAdapter.ViewHolder(view) { + private val titleTextView = view.findViewById(R.id.crypto_status_title) + private val descriptionTextView = view.findViewById(R.id.crypto_status_description) + private val imageView = view.findViewById(R.id.crypto_status_icon) + private val originalBackground = view.background + + override fun bindView(item: CryptoStatusItem, payloads: List) { + val context = itemView.context + val cryptoDetails = item.cryptoDetails + val cryptoStatus = cryptoDetails.cryptoStatus + + imageView.setImageResource(cryptoStatus.statusIconRes) + val tintColor = context.theme.resolveColorAttribute(cryptoStatus.colorAttr) + imageView.imageTintList = ColorStateList.valueOf(tintColor) + + cryptoStatus.titleTextRes?.let { stringResId -> + titleTextView.text = context.getString(stringResId) + } + cryptoStatus.descriptionTextRes?.let { stringResId -> + descriptionTextView.text = context.getString(stringResId) + } + + if (!cryptoDetails.isClickable) { + itemView.background = null + } + } + + override fun unbindView(item: CryptoStatusItem) { + imageView.setImageDrawable(null) + titleTextView.text = null + descriptionTextView.text = null + itemView.background = originalBackground + } + } +} 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 0000000000000000000000000000000000000000..75f6cd59572b27400a7f436883f4dc9dadd9ee51 --- /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 0000000000000000000000000000000000000000..0c5a52d7f355ecde01f24782864eff2acd91b9a8 --- /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 0000000000000000000000000000000000000000..9706b2d4b293b0928c58ce44e435d09e7035285a --- /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 0000000000000000000000000000000000000000..fcf95ef6a245d198eab9e12474075656bdb76ea4 --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDetailsFragment.kt @@ -0,0 +1,313 @@ +package com.fsck.k9.ui.messagedetails + +import android.app.PendingIntent +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.os.bundleOf +import androidx.core.view.isVisible +import androidx.fragment.app.setFragmentResult +import androidx.recyclerview.widget.RecyclerView +import app.k9mail.ui.utils.bottomsheet.ToolbarBottomSheetDialogFragment +import com.fsck.k9.activity.MessageCompose +import com.fsck.k9.contacts.ContactPictureLoader +import com.fsck.k9.controller.MessageReference +import com.fsck.k9.mail.Address +import com.fsck.k9.mailstore.CryptoResultAnnotation +import com.fsck.k9.ui.R +import com.fsck.k9.ui.observe +import com.fsck.k9.ui.withArguments +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 + + // FIXME: Replace this with a mechanism that survives process death + var cryptoResult: CryptoResultAnnotation? = null + + 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) + + cryptoResult?.let { + viewModel.cryptoResult = it + } + + 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.uiEvents.observe(this) { event -> + when (event) { + is MessageDetailEvent.ShowCryptoKeys -> showCryptoKeys(event.pendingIntent) + MessageDetailEvent.SearchCryptoKeys -> searchCryptoKeys() + MessageDetailEvent.ShowCryptoWarning -> showCryptoWarning() + } + } + + viewModel.loadData(messageReference).observe(this) { state -> + when (state) { + MessageDetailsState.Loading -> { + 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))) + + if (details.cryptoDetails != null) { + add(CryptoStatusItem(details.cryptoDetails)) + } + + addParticipants(details.from, R.string.message_details_from_section_title, showContactPicture) + addParticipants(details.sender, R.string.message_details_sender_section_title, showContactPicture) + addParticipants(details.replyTo, R.string.message_details_replyto_section_title, showContactPicture) + + 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(cryptoStatusClickEventHook) + 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 cryptoStatusClickEventHook = object : ClickEventHook() { + override fun onBind(viewHolder: RecyclerView.ViewHolder): View? { + return if (viewHolder is CryptoStatusItem.ViewHolder) { + viewHolder.itemView + } else { + null + } + } + + override fun onClick( + v: View, + position: Int, + fastAdapter: FastAdapter, + item: CryptoStatusItem + ) { + if (item.cryptoDetails.isClickable) { + viewModel.onCryptoStatusClicked() + } + } + } + + 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) + } + } + + private fun showCryptoKeys(pendingIntent: PendingIntent) { + requireActivity().startIntentSender(pendingIntent.intentSender, null, 0, 0, 0) + } + + private fun searchCryptoKeys() { + setFragmentResult(FRAGMENT_RESULT_KEY, bundleOf(RESULT_ACTION to ACTION_SEARCH_KEYS)) + dismiss() + } + + private fun showCryptoWarning() { + setFragmentResult(FRAGMENT_RESULT_KEY, bundleOf(RESULT_ACTION to ACTION_SHOW_WARNING)) + dismiss() + } + + companion object { + private const val ARG_REFERENCE = "reference" + + const val FRAGMENT_RESULT_KEY = "messageDetailsResult" + const val RESULT_ACTION = "action" + const val ACTION_SEARCH_KEYS = "search_keys" + const val ACTION_SHOW_WARNING = "show_warning" + + fun create(messageReference: MessageReference): MessageDetailsFragment { + return MessageDetailsFragment().withArguments( + ARG_REFERENCE to messageReference.toIdentityString() + ) + } + } +} 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 0000000000000000000000000000000000000000..2d333ed2193f8159bf4d58ccbba6843122f6eb4b --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDetailsUi.kt @@ -0,0 +1,29 @@ +package com.fsck.k9.ui.messagedetails + +import android.net.Uri +import com.fsck.k9.mail.Address +import com.fsck.k9.view.MessageCryptoDisplayStatus + +data class MessageDetailsUi( + val date: String?, + val cryptoDetails: CryptoDetails?, + val from: List, + val sender: List, + val replyTo: List, + val to: List, + val cc: List, + val bcc: List +) + +data class CryptoDetails( + val cryptoStatus: MessageCryptoDisplayStatus, + val isClickable: Boolean +) + +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 0000000000000000000000000000000000000000..8232f1f3a8cf473b7ae23b088ba445732beda7eb --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDetailsViewModel.kt @@ -0,0 +1,142 @@ +package com.fsck.k9.ui.messagedetails + +import android.app.PendingIntent +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.CryptoResultAnnotation +import com.fsck.k9.mailstore.MessageDate +import com.fsck.k9.mailstore.MessageRepository +import com.fsck.k9.ui.R +import com.fsck.k9.view.MessageCryptoDisplayStatus +import java.text.DateFormat +import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch + +internal class MessageDetailsViewModel( + 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) + private val eventChannel = Channel() + + val uiEvents = eventChannel.receiveAsFlow() + var cryptoResult: CryptoResultAnnotation? = null + + 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), + cryptoDetails = cryptoResult?.toCryptoDetails(), + 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 CryptoResultAnnotation.toCryptoDetails(): CryptoDetails { + val messageCryptoDisplayStatus = MessageCryptoDisplayStatus.fromResultAnnotation(this) + return CryptoDetails( + cryptoStatus = messageCryptoDisplayStatus, + isClickable = messageCryptoDisplayStatus.hasAssociatedKey() || messageCryptoDisplayStatus.isUnknownKey || + hasOpenPgpInsecureWarningPendingIntent() + ) + } + + private fun List
.toParticipants(): List { + return this.map { address -> + Participant( + address = address, + contactLookupUri = contacts.getContactUri(address.address) + ) + } + } + + fun onCryptoStatusClicked() { + val cryptoResult = cryptoResult ?: return + val cryptoStatus = MessageCryptoDisplayStatus.fromResultAnnotation(cryptoResult) + + if (cryptoStatus.hasAssociatedKey()) { + val pendingIntent = cryptoResult.openPgpSigningKeyIntentIfAny + if (pendingIntent != null) { + viewModelScope.launch { + eventChannel.send(MessageDetailEvent.ShowCryptoKeys(pendingIntent)) + } + } + } else if (cryptoStatus.isUnknownKey) { + viewModelScope.launch { + eventChannel.send(MessageDetailEvent.SearchCryptoKeys) + } + } else if (cryptoResult.hasOpenPgpInsecureWarningPendingIntent()) { + viewModelScope.launch { + eventChannel.send(MessageDetailEvent.ShowCryptoWarning) + } + } + } + + fun onCopyEmailAddressToClipboard(participant: Participant) { + val label = resources.getString(R.string.clipboard_label_email_address) + val emailAddress = participant.address.address + 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 +} + +sealed interface MessageDetailEvent { + data class ShowCryptoKeys(val pendingIntent: PendingIntent) : MessageDetailEvent + object SearchCryptoKeys : MessageDetailEvent + object ShowCryptoWarning : MessageDetailEvent +} 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 0000000000000000000000000000000000000000..51800bd8c0d89891496bf9d8b027cb2d9eb64c84 --- /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 0000000000000000000000000000000000000000..487aba7290704b0eb41fba8d6e60d04d424223ee --- /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 0000000000000000000000000000000000000000..b6ef5d799abb40588fb6cc760becc2242a93977f --- /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/messagelist/MessageListFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt index e0f6abdc21706ecba6bd3f965d1871c8ce88b8a9..9f5261fdf161239d7faea05c43f273afe4d9b0c2 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt @@ -165,6 +165,9 @@ class MessageListFragment : maybeHideFloatingActionButton() } + val isShowAccountChip: Boolean + get() = !isSingleAccountMode + override fun onAttach(context: Context) { super.onAttach(context) @@ -575,7 +578,7 @@ class MessageListFragment : if (currentFolder.moreMessages && !localSearch.isManualSearch) { val folderId = currentFolder.databaseId - messagingController.loadMoreMessages(account, folderId, null) + messagingController.loadMoreMessages(account, folderId) } else if (isRemoteSearch) { val additionalSearchResults = extraSearchResults ?: return if (additionalSearchResults.isEmpty()) return @@ -609,7 +612,7 @@ class MessageListFragment : preferences.accounts .filter { account -> account.isFinishedSetup && account.inboxFolderId != null } .forEach { - messagingController.loadMoreMessages(it, it.inboxFolderId!!, null) + messagingController.loadMoreMessages(it, it.inboxFolderId!!) } updateFooterText(null) return true @@ -675,7 +678,7 @@ class MessageListFragment : showContactPicture = K9.isShowContactPicture, showingThreadedList = showingThreadedList, backGroundAsReadIndicator = K9.isUseBackgroundAsUnreadIndicator, - showAccountChip = !isSingleAccountMode + showAccountChip = isShowAccountChip ) private fun getFolderInfoHolder(folderId: Long, account: Account): FolderInfoHolder { diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLoader.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLoader.kt index 0409309cbf1105ca81267dee22dbd0c36674c79d..595e3f115a71ee1346eaf9010bbdd2727708edba 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLoader.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLoader.kt @@ -77,7 +77,7 @@ class MessageListLoader( queryArgs.add(activeMessage.folderId.toString()) } - SqlQueryBuilder.buildWhereClause(account, config.search.conditions, query, queryArgs) + SqlQueryBuilder.buildWhereClause(config.search.conditions, query, queryArgs) if (selectActive) { query.append(')') diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/CryptoInfoDialog.java b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/CryptoInfoDialog.java deleted file mode 100644 index cdbc6d3b98419ad6fe51ecca45a3b72f6e90bf77..0000000000000000000000000000000000000000 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/CryptoInfoDialog.java +++ /dev/null @@ -1,140 +0,0 @@ -package com.fsck.k9.ui.messageview; - - -import android.annotation.SuppressLint; -import android.app.AlertDialog; -import android.app.AlertDialog.Builder; -import android.app.Dialog; -import android.content.DialogInterface; -import android.content.DialogInterface.OnClickListener; -import android.os.Bundle; -import androidx.annotation.AttrRes; -import androidx.annotation.ColorInt; -import androidx.annotation.DrawableRes; -import androidx.annotation.StringRes; -import androidx.fragment.app.DialogFragment; -import androidx.fragment.app.Fragment; -import android.view.LayoutInflater; -import android.view.View; -import android.widget.ImageView; -import android.widget.TextView; - -import com.fsck.k9.ui.R; -import com.fsck.k9.view.MessageCryptoDisplayStatus; -import com.fsck.k9.view.ThemeUtils; - - -public class CryptoInfoDialog extends DialogFragment { - public static final String ARG_DISPLAY_STATUS = "display_status"; - public static final String ARG_HAS_SECURITY_WARNING = "has_security_warning"; - - - private ImageView statusIcon; - private TextView titleText; - private TextView descriptionText; - - - public static CryptoInfoDialog newInstance(MessageCryptoDisplayStatus displayStatus, boolean hasSecurityWarning) { - CryptoInfoDialog frag = new CryptoInfoDialog(); - - Bundle args = new Bundle(); - args.putString(ARG_DISPLAY_STATUS, displayStatus.toString()); - args.putBoolean(ARG_HAS_SECURITY_WARNING, hasSecurityWarning); - frag.setArguments(args); - - return frag; - } - - @SuppressLint("InflateParams") // inflating without root element is fine for creating a dialog - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - Builder b = new AlertDialog.Builder(getActivity()); - - View dialogView = LayoutInflater.from(getActivity()).inflate(R.layout.message_crypto_info_dialog, null); - - statusIcon = dialogView.findViewById(R.id.crypto_info_top_icon_1); - titleText = dialogView.findViewById(R.id.crypto_info_title); - descriptionText = dialogView.findViewById(R.id.crypto_info_text); - - MessageCryptoDisplayStatus displayStatus = - MessageCryptoDisplayStatus.valueOf(getArguments().getString(ARG_DISPLAY_STATUS)); - setMessageForDisplayStatus(displayStatus); - - b.setView(dialogView); - b.setPositiveButton(R.string.crypto_info_ok, new OnClickListener() { - @Override - public void onClick(DialogInterface dialogInterface, int i) { - dismiss(); - } - }); - boolean hasSecurityWarning = getArguments().getBoolean(ARG_HAS_SECURITY_WARNING); - if (hasSecurityWarning) { - b.setNeutralButton(R.string.crypto_info_view_security_warning, new OnClickListener() { - @Override - public void onClick(DialogInterface dialogInterface, int i) { - Fragment frag = getTargetFragment(); - if (!(frag instanceof OnClickShowCryptoKeyListener)) { - throw new AssertionError("Displaying activity must implement OnClickShowCryptoKeyListener!"); - } - ((OnClickShowCryptoKeyListener) frag).onClickShowSecurityWarning(); - } - }); - } else if (displayStatus.isUnknownKey()) { - b.setNeutralButton(R.string.crypto_info_search_key, new OnClickListener() { - @Override - public void onClick(DialogInterface dialogInterface, int i) { - Fragment frag = getTargetFragment(); - if (! (frag instanceof OnClickShowCryptoKeyListener)) { - throw new AssertionError("Displaying activity must implement OnClickShowCryptoKeyListener!"); - } - ((OnClickShowCryptoKeyListener) frag).onClickSearchKey(); - } - }); - } else if (displayStatus.hasAssociatedKey()) { - int buttonLabel = displayStatus.isUnencryptedSigned() ? - R.string.crypto_info_view_signer : R.string.crypto_info_view_sender; - b.setNeutralButton(buttonLabel, new OnClickListener() { - @Override - public void onClick(DialogInterface dialogInterface, int i) { - Fragment frag = getTargetFragment(); - if (! (frag instanceof OnClickShowCryptoKeyListener)) { - throw new AssertionError("Displaying activity must implement OnClickShowCryptoKeyListener!"); - } - ((OnClickShowCryptoKeyListener) frag).onClickShowCryptoKey(); - } - }); - } - - return b.create(); - } - - private void setMessageForDisplayStatus(MessageCryptoDisplayStatus displayStatus) { - if (displayStatus.getTitleTextRes() == null) { - throw new AssertionError("Crypto info dialog can only be displayed for items with text!"); - } - - setMessageSingleLine(displayStatus.getColorAttr(), displayStatus.getTitleTextRes(), - displayStatus.getDescriptionTextRes(), displayStatus.getStatusIconRes()); - } - - private void setMessageSingleLine(@AttrRes int colorAttr, @StringRes int titleTextRes, - @StringRes Integer descTextRes, @DrawableRes int statusIconRes) { - @ColorInt int color = ThemeUtils.getStyledColor(getActivity(), colorAttr); - - statusIcon.setImageResource(statusIconRes); - statusIcon.setColorFilter(color); - titleText.setText(titleTextRes); - if (descTextRes != null) { - descriptionText.setText(descTextRes); - descriptionText.setVisibility(View.VISIBLE); - } else { - descriptionText.setVisibility(View.GONE); - } - } - - public interface OnClickShowCryptoKeyListener { - void onClickShowCryptoKey(); - void onClickShowSecurityWarning(); - void onClickSearchKey(); - } -} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/DisplayRecipientsExtractor.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/DisplayRecipientsExtractor.kt new file mode 100644 index 0000000000000000000000000000000000000000..269c5cb317f62a8f180cc46d2f4b86c7fe767a34 --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/DisplayRecipientsExtractor.kt @@ -0,0 +1,59 @@ +package com.fsck.k9.ui.messageview + +import com.fsck.k9.Account +import com.fsck.k9.helper.AddressFormatter +import com.fsck.k9.mail.Address +import com.fsck.k9.mail.Message + +/** + * Extract recipient names from a message to display them in the message view. + * + * This class extracts up to [maxNumberOfDisplayRecipients] recipients from the message and converts them to their + * display name using an [AddressFormatter]. + */ +internal class DisplayRecipientsExtractor( + private val addressFormatter: AddressFormatter, + private val maxNumberOfDisplayRecipients: Int +) { + fun extractDisplayRecipients(message: Message, account: Account): DisplayRecipients { + val toRecipients = message.getRecipients(Message.RecipientType.TO) + val ccRecipients = message.getRecipients(Message.RecipientType.CC) + val bccRecipients = message.getRecipients(Message.RecipientType.BCC) + + val numberOfRecipients = toRecipients.size + ccRecipients.size + bccRecipients.size + + val identity = sequenceOf(toRecipients, ccRecipients, bccRecipients) + .flatMap { addressArray -> addressArray.asSequence() } + .mapNotNull { address -> account.findIdentity(address) } + .firstOrNull() + + val identityEmail = identity?.email + val maxAdditionalRecipients = if (identity != null) { + maxNumberOfDisplayRecipients - 1 + } else { + maxNumberOfDisplayRecipients + } + + val recipientNames = sequenceOf(toRecipients, ccRecipients, bccRecipients) + .flatMap { addressArray -> addressArray.asSequence() } + .filter { address -> address.address != identityEmail } + .map { address -> addressFormatter.getDisplayName(address) } + .take(maxAdditionalRecipients) + .toList() + + return if (identity != null) { + val identityAddress = Address(identity.email) + val meName = addressFormatter.getDisplayName(identityAddress) + val recipients = listOf(meName) + recipientNames + + DisplayRecipients(recipients, numberOfRecipients) + } else { + DisplayRecipients(recipientNames, numberOfRecipients) + } + } +} + +internal data class DisplayRecipients( + val recipientNames: List, + val numberOfRecipients: Int +) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageCryptoPresenter.java b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageCryptoPresenter.java index 6a192e5ea9dfdb671daf887b1de683d3de5981fc..260ff8b81aadea70a5a09c935538fc6536f4ad28 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageCryptoPresenter.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageCryptoPresenter.java @@ -20,7 +20,7 @@ import timber.log.Timber; @SuppressWarnings("WeakerAccess") -public class MessageCryptoPresenter implements OnCryptoClickListener { +public class MessageCryptoPresenter { public static final int REQUEST_CODE_UNKNOWN_KEY = 123; public static final int REQUEST_CODE_SECURITY_WARNING = 124; @@ -38,6 +38,10 @@ public class MessageCryptoPresenter implements OnCryptoClickListener { this.messageCryptoMvpView = messageCryptoMvpView; } + public CryptoResultAnnotation getCryptoResultAnnotation() { + return cryptoResultAnnotation; + } + public void onResume() { if (reloadOnResumeWithoutRecreateFlag) { reloadOnResumeWithoutRecreateFlag = false; @@ -96,23 +100,6 @@ public class MessageCryptoPresenter implements OnCryptoClickListener { return true; } - @Override - public void onCryptoClick() { - if (cryptoResultAnnotation == null) { - return; - } - MessageCryptoDisplayStatus displayStatus = - MessageCryptoDisplayStatus.fromResultAnnotation(cryptoResultAnnotation); - switch (displayStatus) { - case LOADING: - // no need to do anything, there is a progress bar... - break; - default: - displayCryptoInfoDialog(displayStatus); - break; - } - } - @SuppressWarnings("UnusedParameters") // for consistency with Activity.onActivityResult public void onActivityResult(int requestCode, int resultCode, Intent data) { if (requestCode == REQUEST_CODE_UNKNOWN_KEY) { @@ -128,11 +115,6 @@ public class MessageCryptoPresenter implements OnCryptoClickListener { } } - private void displayCryptoInfoDialog(MessageCryptoDisplayStatus displayStatus) { - messageCryptoMvpView.showCryptoInfoDialog( - displayStatus, cryptoResultAnnotation.hasOpenPgpInsecureWarningPendingIntent()); - } - void onClickSearchKey() { try { PendingIntent pendingIntent = cryptoResultAnnotation.getOpenPgpSigningKeyIntentIfAny(); @@ -145,18 +127,6 @@ public class MessageCryptoPresenter implements OnCryptoClickListener { } } - public void onClickShowCryptoKey() { - try { - PendingIntent pendingIntent = cryptoResultAnnotation.getOpenPgpSigningKeyIntentIfAny(); - if (pendingIntent != null) { - messageCryptoMvpView.startPendingIntentForCryptoPresenter( - pendingIntent.getIntentSender(), null, null, 0, 0, 0); - } - } catch (IntentSender.SendIntentException e) { - Timber.e(e, "SendIntentException"); - } - } - public void onClickRetryCryptoOperation() { messageCryptoMvpView.restartMessageCryptoProcessing(); } @@ -204,7 +174,6 @@ public class MessageCryptoPresenter implements OnCryptoClickListener { void startPendingIntentForCryptoPresenter(IntentSender si, Integer requestCode, Intent fillIntent, int flagsMask, int flagValues, int extraFlags) throws IntentSender.SendIntentException; - void showCryptoInfoDialog(MessageCryptoDisplayStatus displayStatus, boolean hasSecurityWarning); void showCryptoConfigDialog(); } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageHeaderClickListener.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageHeaderClickListener.kt new file mode 100644 index 0000000000000000000000000000000000000000..601d25be373b9f605441159602d620878dd25195 --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageHeaderClickListener.kt @@ -0,0 +1,6 @@ +package com.fsck.k9.ui.messageview + +interface MessageHeaderClickListener { + fun onParticipantsContainerClick() + fun onMenuItemClick(itemId: Int) +} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.java b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.java index 0e5e910c32bb973893378b98782071e0e62c688d..d1a6c9fe83118ffc74c02d1937f2350a7238b334 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.java @@ -9,7 +9,7 @@ import android.graphics.drawable.Drawable; import android.os.Parcel; import android.os.Parcelable; import androidx.annotation.NonNull; -import androidx.appcompat.widget.PopupMenu.OnMenuItemClickListener; + import android.util.AttributeSet; import android.view.LayoutInflater; import android.view.View; @@ -51,10 +51,13 @@ public class MessageTopView extends LinearLayout { private ViewGroup containerView; private Button mDownloadRemainder; private AttachmentViewCallback attachmentCallback; + private View extraHeaderContainer; private Button showPicturesButton; private boolean isShowingProgress; private boolean showPicturesButtonClicked; + private boolean showAccountChip; + private MessageCryptoPresenter messageCryptoPresenter; @@ -76,6 +79,7 @@ public class MessageTopView extends LinearLayout { mDownloadRemainder = findViewById(R.id.download_remainder); mDownloadRemainder.setVisibility(View.GONE); + extraHeaderContainer = findViewById(R.id.extra_header_container); showPicturesButton = findViewById(R.id.show_pictures); setShowPicturesButtonListener(); @@ -84,6 +88,10 @@ public class MessageTopView extends LinearLayout { hideHeaderView(); } + public void setShowAccountChip(boolean showAccountChip) { + this.showAccountChip = showAccountChip; + } + private void setShowPicturesButtonListener() { showPicturesButton.setOnClickListener(new OnClickListener() { @Override @@ -208,7 +216,7 @@ public class MessageTopView extends LinearLayout { } public void setHeaders(Message message, Account account, boolean showStar) { - mHeaderContainer.populate(message, account, showStar); + mHeaderContainer.populate(message, account, showStar, showAccountChip); mHeaderContainer.setVisibility(View.VISIBLE); } @@ -220,8 +228,8 @@ public class MessageTopView extends LinearLayout { mHeaderContainer.setOnFlagListener(listener); } - public void setOnMenuItemClickListener(OnMenuItemClickListener listener) { - mHeaderContainer.setOnMenuItemClickListener(listener); + public void setMessageHeaderClickListener(MessageHeaderClickListener listener) { + mHeaderContainer.setMessageHeaderClickListener(listener); } private void hideHeaderView() { @@ -238,7 +246,6 @@ public class MessageTopView extends LinearLayout { public void setMessageCryptoPresenter(MessageCryptoPresenter messageCryptoPresenter) { this.messageCryptoPresenter = messageCryptoPresenter; - mHeaderContainer.setOnCryptoClickListener(messageCryptoPresenter); } public void enableDownloadButton() { @@ -259,11 +266,11 @@ public class MessageTopView extends LinearLayout { } private void showShowPicturesButton() { - showPicturesButton.setVisibility(View.VISIBLE); + extraHeaderContainer.setVisibility(View.VISIBLE); } private void hideShowPicturesButton() { - showPicturesButton.setVisibility(View.GONE); + extraHeaderContainer.setVisibility(View.GONE); } private boolean shouldAutomaticallyLoadPictures(ShowPictures showPicturesSetting, Message message) { diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewContainerFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewContainerFragment.kt index d5dd3509f801acd959124df402364cd4a9bbf8b6..f2caf1b71b88a0b149ce18ea18eefbd982031698 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewContainerFragment.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewContainerFragment.kt @@ -29,6 +29,8 @@ class MessageViewContainerFragment : Fragment() { setMenuVisibility(value) } + private var showAccountChip: Boolean = true + lateinit var messageReference: MessageReference private set @@ -72,7 +74,9 @@ class MessageViewContainerFragment : Fragment() { lastDirection = savedInstanceState.getSerializable(STATE_LAST_DIRECTION) as Direction? } - adapter = MessageViewContainerAdapter(this) + showAccountChip = arguments?.getBoolean(ARG_SHOW_ACCOUNT_CHIP) ?: showAccountChip + + adapter = MessageViewContainerAdapter(this, showAccountChip) } override fun onAttach(context: Context) { @@ -229,7 +233,11 @@ class MessageViewContainerFragment : Fragment() { findMessageViewFragment().onPendingIntentResult(requestCode, resultCode, data) } - private class MessageViewContainerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { + private class MessageViewContainerAdapter( + fragment: Fragment, + private val showAccountChip: Boolean + ) : FragmentStateAdapter(fragment) { + var messageList: List = emptyList() set(value) { val diffResult = DiffUtil.calculateDiff( @@ -257,7 +265,7 @@ class MessageViewContainerFragment : Fragment() { check(position in messageList.indices) val messageReference = messageList[position].messageReference - return MessageViewFragment.newInstance(messageReference) + return MessageViewFragment.newInstance(messageReference, showAccountChip) } fun getMessageReference(position: Int): MessageReference? { @@ -298,13 +306,15 @@ class MessageViewContainerFragment : Fragment() { companion object { private const val ARG_REFERENCE = "reference" + private const val ARG_SHOW_ACCOUNT_CHIP = "showAccountChip" private const val STATE_MESSAGE_REFERENCE = "messageReference" private const val STATE_LAST_DIRECTION = "lastDirection" - fun newInstance(reference: MessageReference): MessageViewContainerFragment { + fun newInstance(reference: MessageReference, showAccountChip: Boolean): MessageViewContainerFragment { return MessageViewContainerFragment().withArguments( - ARG_REFERENCE to reference.toIdentityString() + ARG_REFERENCE to reference.toIdentityString(), + ARG_SHOW_ACCOUNT_CHIP to showAccountChip ) } } 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 54fc7634789fc53e82b0d17d1978e78d2b49761e..b1d70c222c3e4bf849d48352d01711b2b0c43b99 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 @@ -21,6 +21,7 @@ import androidx.core.app.ActivityCompat import androidx.core.content.withStyledAttributes import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResultListener import com.fsck.k9.Account import com.fsck.k9.K9 import com.fsck.k9.activity.MessageCompose @@ -45,13 +46,12 @@ 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 import com.fsck.k9.ui.settings.account.AccountSettingsActivity import com.fsck.k9.ui.share.ShareIntentBuilder import com.fsck.k9.ui.withArguments -import com.fsck.k9.view.MessageCryptoDisplayStatus import java.util.Locale import org.koin.android.ext.android.inject import timber.log.Timber @@ -59,8 +59,7 @@ import timber.log.Timber class MessageViewFragment : Fragment(), ConfirmationDialogFragmentListener, - AttachmentViewCallback, - OnClickShowCryptoKeyListener { + AttachmentViewCallback { private val themeManager: ThemeManager by inject() private val messageLoaderHelperFactory: MessageLoaderHelperFactory by inject() @@ -86,6 +85,7 @@ class MessageViewFragment : private lateinit var account: Account lateinit var messageReference: MessageReference + private var showAccountChip: Boolean = true private var currentAttachmentViewInfo: AttachmentViewInfo? = null private var isDeleteMenuItemDisabled: Boolean = false @@ -118,6 +118,9 @@ class MessageViewFragment : messageReference = MessageReference.parse(arguments?.getString(ARG_REFERENCE)) ?: error("Invalid argument '$ARG_REFERENCE'") + showAccountChip = arguments?.getBoolean(ARG_SHOW_ACCOUNT_CHIP) + ?: error("Missing argument: '$ARG_SHOW_ACCOUNT_CHIP'") + if (savedInstanceState != null) { wasMessageMarkedAsOpened = savedInstanceState.getBoolean(STATE_WAS_MESSAGE_MARKED_AS_OPENED) } @@ -129,6 +132,8 @@ class MessageViewFragment : fragmentManager = parentFragmentManager, callback = messageLoaderCallbacks ) + + setFragmentResultListener(MessageDetailsFragment.FRAGMENT_RESULT_KEY, ::onMessageDetailsResult) } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { @@ -145,6 +150,8 @@ class MessageViewFragment : } private fun initializeMessageTopView(messageTopView: MessageTopView) { + messageTopView.setShowAccountChip(showAccountChip) + messageTopView.setAttachmentCallback(this) messageTopView.setMessageCryptoPresenter(messageCryptoPresenter) @@ -152,9 +159,7 @@ class MessageViewFragment : onToggleFlagged() } - messageTopView.setOnMenuItemClickListener { item -> - onReplyMenuItemClicked(item.itemId) - } + messageTopView.setMessageHeaderClickListener(messageHeaderClickListener) messageTopView.setOnDownloadButtonClickListener { onDownloadButtonClicked() @@ -376,17 +381,23 @@ class MessageViewFragment : messageTopView.setSubject(displaySubject) } - private fun onReplyMenuItemClicked(itemId: Int): Boolean { - when (itemId) { - R.id.reply -> onReply() - R.id.reply_all -> onReplyAll() - R.id.forward -> onForward() - R.id.forward_as_attachment -> onForwardAsAttachment() - R.id.share -> onSendAlternate() - else -> error("Missing handler for reply menu item $itemId") + private val messageHeaderClickListener = object : MessageHeaderClickListener { + override fun onParticipantsContainerClick() { + val messageDetailsFragment = MessageDetailsFragment.create(messageReference) + messageDetailsFragment.cryptoResult = messageCryptoPresenter.cryptoResultAnnotation + messageDetailsFragment.show(parentFragmentManager, "message_details") } - return true + override fun onMenuItemClick(itemId: Int) { + when (itemId) { + R.id.reply -> onReply() + R.id.reply_all -> onReplyAll() + R.id.forward -> onForward() + R.id.forward_as_attachment -> onForwardAsAttachment() + R.id.share -> onSendAlternate() + else -> error("Missing handler for reply menu item $itemId") + } + } } private fun onDownloadButtonClicked() { @@ -568,6 +579,20 @@ class MessageViewFragment : } } + private fun onMessageDetailsResult(requestKey: String, result: Bundle) { + when (val action = result.getString(MessageDetailsFragment.RESULT_ACTION)) { + MessageDetailsFragment.ACTION_SEARCH_KEYS -> { + messageCryptoPresenter.onClickSearchKey() + } + MessageDetailsFragment.ACTION_SHOW_WARNING -> { + messageCryptoPresenter.onClickShowCryptoWarningDetails() + } + else -> { + error("Unsupported action: $action") + } + } + } + private fun onCreateDocumentResult(data: Intent?) { if (data != null && data.data != null) { createAttachmentController(currentAttachmentViewInfo).saveAttachmentTo(data.data) @@ -806,12 +831,6 @@ class MessageViewFragment : ) } - override fun showCryptoInfoDialog(displayStatus: MessageCryptoDisplayStatus, hasSecurityWarning: Boolean) { - val dialog = CryptoInfoDialog.newInstance(displayStatus, hasSecurityWarning) - dialog.setTargetFragment(this@MessageViewFragment, 0) - dialog.show(parentFragmentManager, "crypto_info_dialog") - } - override fun restartMessageCryptoProcessing() { messageTopView.setToLoadingState() messageLoaderHelper.asyncRestartMessageCryptoProcessing() @@ -822,18 +841,6 @@ class MessageViewFragment : } } - override fun onClickShowSecurityWarning() { - messageCryptoPresenter.onClickShowCryptoWarningDetails() - } - - override fun onClickSearchKey() { - messageCryptoPresenter.onClickSearchKey() - } - - override fun onClickShowCryptoKey() { - messageCryptoPresenter.onClickShowCryptoKey() - } - interface MessageViewFragmentListener { fun onForward(messageReference: MessageReference, decryptionResultForReply: Parcelable?) fun onForwardAsAttachment(messageReference: MessageReference, decryptionResultForReply: Parcelable?) @@ -960,6 +967,7 @@ class MessageViewFragment : const val PROGRESS_THRESHOLD_MILLIS = 500 * 1000 private const val ARG_REFERENCE = "reference" + private const val ARG_SHOW_ACCOUNT_CHIP = "showAccountChip" private const val STATE_WAS_MESSAGE_MARKED_AS_OPENED = "wasMessageMarkedAsOpened" @@ -967,9 +975,10 @@ class MessageViewFragment : private const val ACTIVITY_CHOOSE_FOLDER_COPY = 2 private const val REQUEST_CODE_CREATE_DOCUMENT = 3 - fun newInstance(reference: MessageReference): MessageViewFragment { + fun newInstance(reference: MessageReference, showAccountChip: Boolean): MessageViewFragment { return MessageViewFragment().withArguments( - ARG_REFERENCE to reference.toIdentityString() + ARG_REFERENCE to reference.toIdentityString(), + ARG_SHOW_ACCOUNT_CHIP to showAccountChip ) } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/OnCryptoClickListener.java b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/OnCryptoClickListener.java deleted file mode 100644 index 7df9d45e787a7026caa119fad13b038898e620d0..0000000000000000000000000000000000000000 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/OnCryptoClickListener.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.fsck.k9.ui.messageview; - - -public interface OnCryptoClickListener { - void onCryptoClick(); -} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/RecipientLayoutCreator.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/RecipientLayoutCreator.kt new file mode 100644 index 0000000000000000000000000000000000000000..179caaae88e553e8cf4445f676e9bf305a47837f --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/RecipientLayoutCreator.kt @@ -0,0 +1,119 @@ +package com.fsck.k9.ui.messageview + +import android.text.SpannableStringBuilder + +private const val LIST_SEPARATOR = ", " + +/** + * Calculates how many recipient names can be displayed given the available width. + * + * We display up to [maxNumberOfRecipientNames] recipient names, then the number of additional recipients. + * + * Example: + * to me, Alice, Bob, Charly, Dora +11 + * + * If there's not enough room to display the first recipient name, we return it anyway and expect the component that is + * actually rendering the text to ellipsize [RecipientLayoutData.recipientNames], but not + * [RecipientLayoutData.additionalRecipients]. + */ +internal class RecipientLayoutCreator( + private val textMeasure: TextMeasure, + private val maxNumberOfRecipientNames: Int, + private val recipientsPrefix: String, + private val additionalRecipientSpacing: Int, + private val additionalRecipientsPrefix: String +) { + fun createRecipientLayout( + recipientNames: List, + totalNumberOfRecipients: Int, + availableWidth: Int + ): RecipientLayoutData { + require(recipientNames.isNotEmpty()) + + val displayRecipientsBuilder = SpannableStringBuilder() + + if (recipientNames.size == 1) { + displayRecipientsBuilder.append(recipientsPrefix) + displayRecipientsBuilder.append(recipientNames.first()) + + return RecipientLayoutData( + recipientNames = displayRecipientsBuilder, + additionalRecipients = null + ) + } + + val additionalRecipientsBuilder = StringBuilder(additionalRecipientsPrefix + 10) + + val maxRecipientNames = recipientNames.size.coerceAtMost(maxNumberOfRecipientNames) + for (numberOfDisplayRecipients in maxRecipientNames downTo 2) { + displayRecipientsBuilder.clear() + displayRecipientsBuilder.append(recipientsPrefix) + + recipientNames.asSequence() + .take(numberOfDisplayRecipients) + .joinTo(displayRecipientsBuilder, separator = LIST_SEPARATOR) + + additionalRecipientsBuilder.setLength(0) + val numberOfAdditionalRecipients = totalNumberOfRecipients - numberOfDisplayRecipients + if (numberOfAdditionalRecipients > 0) { + additionalRecipientsBuilder.append(additionalRecipientsPrefix) + additionalRecipientsBuilder.append(numberOfAdditionalRecipients) + } + + if (doesTextFitAvailableWidth(displayRecipientsBuilder, additionalRecipientsBuilder, availableWidth)) { + return RecipientLayoutData( + recipientNames = displayRecipientsBuilder, + additionalRecipients = additionalRecipientsBuilder.toStringOrNull() + ) + } + } + + displayRecipientsBuilder.clear() + displayRecipientsBuilder.append(recipientsPrefix) + displayRecipientsBuilder.append(recipientNames.first()) + + return RecipientLayoutData( + recipientNames = displayRecipientsBuilder, + additionalRecipients = "$additionalRecipientsPrefix${totalNumberOfRecipients - 1}" + ) + } + + private fun doesTextFitAvailableWidth( + displayRecipients: CharSequence, + additionalRecipients: CharSequence, + availableWidth: Int + ): Boolean { + val recipientNamesWidth = textMeasure.measureRecipientNames(displayRecipients) + if (recipientNamesWidth > availableWidth) { + return false + } else if (additionalRecipients.isEmpty()) { + return true + } + + val totalWidth = recipientNamesWidth + additionalRecipientSpacing + + textMeasure.measureRecipientCount(additionalRecipients) + + return totalWidth <= availableWidth + } +} + +private fun StringBuilder.toStringOrNull(): String? { + return if (isEmpty()) null else toString() +} + +internal data class RecipientLayoutData( + val recipientNames: CharSequence, + val additionalRecipients: String? +) + +internal interface TextMeasure { + /** + * Measure the width of the supplied recipient names when rendered. + */ + fun measureRecipientNames(text: CharSequence): Int + + /** + * Measure the width of the supplied recipient count when rendered. + */ + fun measureRecipientCount(text: CharSequence): Int +} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/RecipientNamesView.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/RecipientNamesView.kt new file mode 100644 index 0000000000000000000000000000000000000000..c1c1c89a02d01d0fc898da5aa4f697a38e960546 --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/RecipientNamesView.kt @@ -0,0 +1,189 @@ +package com.fsck.k9.ui.messageview + +import android.content.Context +import android.util.AttributeSet +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.view.isGone +import com.fsck.k9.ui.R + +private const val MAX_NUMBER_OF_RECIPIENT_NAMES = 5 + +/** + * View that displays the names of recipients of a message. + * + * Up to [MAX_NUMBER_OF_RECIPIENT_NAMES] names of recipients are displayed, followed by the number of recipients that + * weren't displayed. + * + * Examples: + * - to me, Alice, Bob, Charly +3 + * - to Camila Hyphenated-Nam… +5 + * + * This custom layout uses [RecipientLayoutCreator] to figure out how many recipient names can be displayed without + * being truncated. If not even one recipient name can be displayed without being truncated, we first measure the space + * needed for number of additional recipients, then use the rest to display the first recipient and ellipsize the end. + */ +class RecipientNamesView(context: Context, attrs: AttributeSet?) : ViewGroup(context, attrs) { + val maxNumberOfRecipientNames: Int = MAX_NUMBER_OF_RECIPIENT_NAMES + + private val recipientLayoutCreator: RecipientLayoutCreator + + private val recipientNameTextView: TextView + private val recipientCountTextView: TextView + private val additionRecipientSpacing: Int + + init { + LayoutInflater.from(context).inflate(R.layout.recipient_names, this, true) + recipientNameTextView = findViewById(R.id.recipient_names) + recipientCountTextView = findViewById(R.id.recipient_count) + additionRecipientSpacing = (recipientCountTextView.layoutParams as MarginLayoutParams).marginStart + } + + private var recipientNames: List = emptyList() + private var numberOfRecipients: Int = 0 + + private val textMeasure = object : TextMeasure { + override fun measureRecipientNames(text: CharSequence): Int { + return measureWidth(recipientNameTextView, text) + } + + override fun measureRecipientCount(text: CharSequence): Int { + return measureWidth(recipientCountTextView, text) + } + + private fun measureWidth(textView: TextView, text: CharSequence): Int { + textView.text = text + + val widthMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) + val heightMeasureSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.AT_MOST) + textView.measure(widthMeasureSpec, heightMeasureSpec) + + return textView.measuredWidth + } + } + + init { + recipientLayoutCreator = RecipientLayoutCreator( + textMeasure = textMeasure, + maxNumberOfRecipientNames = MAX_NUMBER_OF_RECIPIENT_NAMES, + recipientsPrefix = context.getString(R.string.message_view_recipient_prefix), + additionalRecipientSpacing = additionRecipientSpacing, + additionalRecipientsPrefix = context.getString(R.string.message_view_additional_recipient_prefix) + ) + + if (isInEditMode) { + recipientNames = listOf( + "Grace Hopper", "Katherine Johnson", "Margaret Hamilton", "Adele Goldberg", "Steve Shirley" + ) + numberOfRecipients = 8 + } + } + + fun setTextSize(textSize: Int) { + recipientNameTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSize.toFloat()) + recipientCountTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSize.toFloat()) + } + + fun setRecipients(recipientNames: List, numberOfRecipients: Int) { + if (recipientNames != this.recipientNames && numberOfRecipients != this.numberOfRecipients) { + this.recipientNames = recipientNames + this.numberOfRecipients = numberOfRecipients + requestLayout() + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + require(MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.UNSPECIFIED) { + "Width of RecipientNamesView needs to be constrained" + } + + recipientNameTextView.measure(widthMeasureSpec, heightMeasureSpec) + recipientCountTextView.measure(widthMeasureSpec, heightMeasureSpec) + + val width = MeasureSpec.getSize(widthMeasureSpec) + val height = maxOf(recipientNameTextView.measuredHeight, recipientCountTextView.measuredHeight) + setMeasuredDimension(width, height) + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + if (numberOfRecipients == 0) { + // There's nothing to display + return + } + + val availableWidth = width + + val recipientLayoutData = recipientLayoutCreator.createRecipientLayout( + recipientNames, numberOfRecipients, availableWidth + ) + + recipientNameTextView.text = recipientLayoutData.recipientNames + val additionalRecipientsVisible = recipientLayoutData.additionalRecipients != null + val remainingWidth: Int + if (additionalRecipientsVisible) { + recipientCountTextView.isGone = false + recipientCountTextView.text = recipientLayoutData.additionalRecipients + + recipientCountTextView.measure( + MeasureSpec.makeMeasureSpec(availableWidth, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.AT_MOST) + ) + + remainingWidth = availableWidth - additionRecipientSpacing - recipientCountTextView.measuredWidth + } else { + recipientCountTextView.isGone = true + remainingWidth = availableWidth + } + + recipientNameTextView.measure( + MeasureSpec.makeMeasureSpec(remainingWidth, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.AT_MOST) + ) + + if (layoutDirection == LAYOUT_DIRECTION_LTR) { + val recipientNameRight = recipientNameTextView.measuredWidth + recipientNameTextView.layout( + 0, + 0, + recipientNameRight, + recipientNameTextView.measuredHeight + ) + val recipientCountLeft = recipientNameRight + additionRecipientSpacing + recipientCountTextView.layout( + recipientCountLeft, + 0, + recipientCountLeft + recipientCountTextView.measuredWidth, + recipientCountTextView.measuredHeight + ) + } else { + val recipientNameLeft = width - recipientNameTextView.measuredWidth + recipientNameTextView.layout( + recipientNameLeft, + 0, + right, + recipientNameTextView.measuredHeight + ) + val recipientCountRight = recipientNameLeft - additionRecipientSpacing + recipientCountTextView.layout( + recipientCountRight - recipientCountTextView.measuredWidth, + 0, + recipientCountRight, + 0 + recipientCountTextView.measuredHeight + ) + } + } + + override fun checkLayoutParams(p: LayoutParams?): Boolean { + return p is MarginLayoutParams + } + + override fun generateDefaultLayoutParams(): LayoutParams { + return MarginLayoutParams(0, 0) + } + + override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams { + return MarginLayoutParams(context, attrs) + } +} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/PreferenceExtras.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/PreferenceExtras.kt index 87d6d727292f0fab92ee82e72cec40c99e24ff88..35b9e33c7ae119e7cc9bd9f7e89b53b452c1ea99 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/PreferenceExtras.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/PreferenceExtras.kt @@ -1,7 +1,6 @@ package com.fsck.k9.ui.settings import androidx.preference.ListPreference -import androidx.preference.MultiSelectListPreference import androidx.preference.Preference inline fun Preference.onClick(crossinline action: () -> Unit) = setOnPreferenceClickListener { @@ -17,12 +16,6 @@ fun ListPreference.removeEntry(entryValue: String) { entryValues = entryValues.filterIndexed { index, _ -> index != deleteIndex }.toTypedArray() } -fun MultiSelectListPreference.removeEntry(entryValue: String) { - val deleteIndex = entryValues.indexOf(entryValue) - entries = entries.filterIndexed { index, _ -> index != deleteIndex }.toTypedArray() - entryValues = entryValues.filterIndexed { index, _ -> index != deleteIndex }.toTypedArray() -} - inline fun Preference.oneTimeClickListener(clickHandled: Boolean = true, crossinline block: () -> Unit) { onPreferenceClickListener = Preference.OnPreferenceClickListener { preference -> preference.onPreferenceClickListener = null diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsDataStore.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsDataStore.kt index 9dab6f86493797dbe84677aa465f1eb8cfdbd144..bde889e72d9799893337bd3ffd2e3cc74b4b7e44 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsDataStore.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsDataStore.kt @@ -119,12 +119,9 @@ class GeneralSettingsDataStore( "message_list_date_font" -> K9.fontSizes.messageListDate.toString() "message_list_preview_font" -> K9.fontSizes.messageListPreview.toString() "message_view_sender_font" -> K9.fontSizes.messageViewSender.toString() - "message_view_to_font" -> K9.fontSizes.messageViewTo.toString() - "message_view_cc_font" -> K9.fontSizes.messageViewCC.toString() - "message_view_bcc_font" -> K9.fontSizes.messageViewBCC.toString() + "message_view_recipients_font" -> K9.fontSizes.messageViewRecipients.toString() "message_view_subject_font" -> K9.fontSizes.messageViewSubject.toString() "message_view_date_font" -> K9.fontSizes.messageViewDate.toString() - "message_view_additional_headers_font" -> K9.fontSizes.messageViewAdditionalHeaders.toString() "message_compose_input_font" -> K9.fontSizes.messageComposeInput.toString() "swipe_action_right" -> swipeActionToString(K9.swipeRightAction) "swipe_action_left" -> swipeActionToString(K9.swipeLeftAction) @@ -156,12 +153,9 @@ class GeneralSettingsDataStore( "message_list_date_font" -> K9.fontSizes.messageListDate = value.toInt() "message_list_preview_font" -> K9.fontSizes.messageListPreview = value.toInt() "message_view_sender_font" -> K9.fontSizes.messageViewSender = value.toInt() - "message_view_to_font" -> K9.fontSizes.messageViewTo = value.toInt() - "message_view_cc_font" -> K9.fontSizes.messageViewCC = value.toInt() - "message_view_bcc_font" -> K9.fontSizes.messageViewBCC = value.toInt() + "message_view_recipients_font" -> K9.fontSizes.messageViewRecipients = value.toInt() "message_view_subject_font" -> K9.fontSizes.messageViewSubject = value.toInt() "message_view_date_font" -> K9.fontSizes.messageViewDate = value.toInt() - "message_view_additional_headers_font" -> K9.fontSizes.messageViewAdditionalHeaders = value.toInt() "message_compose_input_font" -> K9.fontSizes.messageComposeInput = value.toInt() "swipe_action_right" -> K9.swipeRightAction = stringToSwipeAction(value) "swipe_action_left" -> K9.swipeLeftAction = stringToSwipeAction(value) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/view/KoinModule.kt b/app/ui/legacy/src/main/java/com/fsck/k9/view/KoinModule.kt index 076cf0c583b862f6f98a70e2f0338744c3b0b17b..269d220fd9c665d1953095c2ada5a3cb19780d73 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/view/KoinModule.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/view/KoinModule.kt @@ -1,7 +1,13 @@ package com.fsck.k9.view +import com.fsck.k9.helper.ReplyToParser +import com.fsck.k9.message.ReplyActionStrategy +import com.fsck.k9.ui.helper.RelativeDateTimeFormatter import org.koin.dsl.module val viewModule = module { - single { WebViewConfigProvider(get()) } + single { WebViewConfigProvider(themeManager = get()) } + factory { RelativeDateTimeFormatter(context = get(), clock = get()) } + factory { ReplyToParser() } + factory { ReplyActionStrategy(replyRoParser = get()) } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/view/MessageHeader.java b/app/ui/legacy/src/main/java/com/fsck/k9/view/MessageHeader.java index dfa67c473711de32c29950f9edfe73efbd339c83..7c63127aeec500f85a8bdd25a9012114bee8a257 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/view/MessageHeader.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/view/MessageHeader.java @@ -1,24 +1,23 @@ package com.fsck.k9.view; -import java.util.Arrays; +import java.util.List; import android.content.Context; -import android.text.TextUtils; -import android.text.format.DateUtils; +import android.content.res.ColorStateList; import android.util.AttributeSet; import android.view.View; import android.view.View.OnClickListener; import android.view.View.OnLongClickListener; -import android.widget.CheckBox; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.appcompat.widget.PopupMenu; -import androidx.appcompat.widget.PopupMenu.OnMenuItemClickListener; +import androidx.appcompat.widget.TooltipCompat; import com.fsck.k9.Account; import com.fsck.k9.DI; import com.fsck.k9.FontSizes; @@ -28,112 +27,90 @@ import com.fsck.k9.contacts.ContactPictureLoader; import com.fsck.k9.helper.ClipboardManager; import com.fsck.k9.helper.Contacts; import com.fsck.k9.helper.MessageHelper; +import com.fsck.k9.helper.RealAddressFormatter; +import com.fsck.k9.helper.RealContactNameProvider; import com.fsck.k9.mail.Address; import com.fsck.k9.mail.Flag; import com.fsck.k9.mail.Message; -import com.fsck.k9.ui.ContactBadge; +import com.fsck.k9.message.ReplyAction; +import com.fsck.k9.message.ReplyActionStrategy; +import com.fsck.k9.message.ReplyActions; import com.fsck.k9.ui.R; -import com.fsck.k9.ui.messageview.OnCryptoClickListener; -import timber.log.Timber; +import com.fsck.k9.ui.helper.RelativeDateTimeFormatter; +import com.fsck.k9.ui.messageview.DisplayRecipients; +import com.fsck.k9.ui.messageview.DisplayRecipientsExtractor; +import com.fsck.k9.ui.messageview.MessageHeaderClickListener; +import com.fsck.k9.ui.messageview.RecipientNamesView; +import com.google.android.material.chip.Chip; public class MessageHeader extends LinearLayout implements OnClickListener, OnLongClickListener { private static final int DEFAULT_SUBJECT_LINES = 3; - private final ClipboardManager clipboardManager = DI.get(ClipboardManager.class); - - private Context mContext; - private TextView mFromView; - private TextView mSenderView; - private TextView mDateView; - private TextView mToView; - private TextView mToLabel; - private TextView mCcView; - private TextView mCcLabel; - private TextView mBccView; - private TextView mBccLabel; - private TextView mSubjectView; - private ImageView mCryptoStatusIcon; - - private View mChip; - private CheckBox mFlagged; - private int defaultSubjectColor; - private View singleMessageOptionIcon; - private View mAnsweredIcon; - private View mForwardedIcon; - private Message mMessage; - private Account mAccount; - private FontSizes mFontSizes = K9.getFontSizes(); - private Contacts mContacts; - - private MessageHelper mMessageHelper; - private ContactPictureLoader mContactsPictureLoader; - private ContactBadge mContactBadge; - - private OnCryptoClickListener onCryptoClickListener; - private OnMenuItemClickListener onMenuItemClickListener; + private final ReplyActionStrategy replyActionStrategy = DI.get(ReplyActionStrategy.class); + private final FontSizes fontSizes = K9.getFontSizes(); + + private Chip accountChip; + private TextView subjectView; + private ImageView starView; + private ImageView contactPictureView; + private TextView fromView; + private ImageView cryptoStatusIcon; + private RecipientNamesView recipientNamesView; + private TextView dateView; + private ImageView menuPrimaryActionView; + + private MessageHelper messageHelper; + private RelativeDateTimeFormatter relativeDateTimeFormatter; + + private MessageHeaderClickListener messageHeaderClickListener; + private ReplyActions replyActions; public MessageHeader(Context context, AttributeSet attrs) { super(context, attrs); - mContext = context; - mContacts = Contacts.getInstance(mContext); + + if (!isInEditMode()) { + messageHelper = MessageHelper.getInstance(getContext()); + relativeDateTimeFormatter = DI.get(RelativeDateTimeFormatter.class); + } } @Override protected void onFinishInflate() { super.onFinishInflate(); - mAnsweredIcon = findViewById(R.id.answered); - mForwardedIcon = findViewById(R.id.forwarded); - mFromView = findViewById(R.id.from); - mSenderView = findViewById(R.id.sender); - mToView = findViewById(R.id.to); - mToLabel = findViewById(R.id.to_label); - mCcView = findViewById(R.id.cc); - mCcLabel = findViewById(R.id.cc_label); - mBccView = findViewById(R.id.bcc); - mBccLabel = findViewById(R.id.bcc_label); - - mContactBadge = findViewById(R.id.contact_badge); - - singleMessageOptionIcon = findViewById(R.id.icon_single_message_options); - - mSubjectView = findViewById(R.id.subject); - mChip = findViewById(R.id.chip); - mDateView = findViewById(R.id.date); - mFlagged = findViewById(R.id.flagged); - - defaultSubjectColor = mSubjectView.getCurrentTextColor(); - mFontSizes.setViewTextSize(mSubjectView, mFontSizes.getMessageViewSubject()); - mFontSizes.setViewTextSize(mDateView, mFontSizes.getMessageViewDate()); - - mFontSizes.setViewTextSize(mFromView, mFontSizes.getMessageViewSender()); - mFontSizes.setViewTextSize(mToView, mFontSizes.getMessageViewTo()); - mFontSizes.setViewTextSize(mToLabel, mFontSizes.getMessageViewTo()); - mFontSizes.setViewTextSize(mCcView, mFontSizes.getMessageViewCC()); - mFontSizes.setViewTextSize(mCcLabel, mFontSizes.getMessageViewCC()); - mFontSizes.setViewTextSize(mBccView, mFontSizes.getMessageViewBCC()); - mFontSizes.setViewTextSize(mBccLabel, mFontSizes.getMessageViewBCC()); - - singleMessageOptionIcon.setOnClickListener(this); - - mSubjectView.setOnClickListener(this); - mFromView.setOnClickListener(this); - mToView.setOnClickListener(this); - mCcView.setOnClickListener(this); - mBccView.setOnClickListener(this); - - mSubjectView.setOnLongClickListener(this); - mFromView.setOnLongClickListener(this); - mToView.setOnLongClickListener(this); - mCcView.setOnLongClickListener(this); - mBccView.setOnLongClickListener(this); - - mCryptoStatusIcon = findViewById(R.id.crypto_status_icon); - mCryptoStatusIcon.setOnClickListener(this); - - mMessageHelper = MessageHelper.getInstance(mContext); + accountChip = findViewById(R.id.chip); + subjectView = findViewById(R.id.subject); + starView = findViewById(R.id.flagged); + contactPictureView = findViewById(R.id.contact_picture); + fromView = findViewById(R.id.from); + cryptoStatusIcon = findViewById(R.id.crypto_status_icon); + recipientNamesView = findViewById(R.id.recipients); + dateView = findViewById(R.id.date); + + fontSizes.setViewTextSize(subjectView, fontSizes.getMessageViewSubject()); + fontSizes.setViewTextSize(dateView, fontSizes.getMessageViewDate()); + fontSizes.setViewTextSize(fromView, fontSizes.getMessageViewSender()); + + int recipientTextSize = fontSizes.getMessageViewRecipients(); + if (recipientTextSize != FontSizes.FONT_DEFAULT) { + recipientNamesView.setTextSize(recipientTextSize); + } + + subjectView.setOnClickListener(this); + subjectView.setOnLongClickListener(this); + + menuPrimaryActionView = findViewById(R.id.menu_primary_action); + menuPrimaryActionView.setOnClickListener(this); + + View menuOverflowView = findViewById(R.id.menu_overflow); + menuOverflowView.setOnClickListener(this); + String menuOverflowDescription = + getContext().getString(androidx.appcompat.R.string.abc_action_menu_overflow_description); + TooltipCompat.setTooltipText(menuOverflowView, menuOverflowDescription); + + findViewById(R.id.participants_container).setOnClickListener(this); } @Override @@ -141,168 +118,218 @@ public class MessageHeader extends LinearLayout implements OnClickListener, OnLo int id = view.getId(); if (id == R.id.subject) { toggleSubjectViewMaxLines(); - } else if (id == R.id.from) { - onAddSenderToContacts(); - } else if (id == R.id.to || id == R.id.cc || id == R.id.bcc) { - expand((TextView)view, ((TextView)view).getEllipsize() != null); - } else if (id == R.id.crypto_status_icon) { - onCryptoClickListener.onCryptoClick(); - } else if (id == R.id.icon_single_message_options) { - PopupMenu popupMenu = new PopupMenu(getContext(), view); - popupMenu.setOnMenuItemClickListener(onMenuItemClickListener); - popupMenu.inflate(R.menu.single_message_options); - popupMenu.show(); + } else if (id == R.id.menu_primary_action) { + performPrimaryReplyAction(); + } else if (id == R.id.menu_overflow) { + showOverflowMenu(view); + } else if (id == R.id.participants_container) { + messageHeaderClickListener.onParticipantsContainerClick(); } } + private void performPrimaryReplyAction() { + ReplyAction defaultAction = replyActions.getDefaultAction(); + if (defaultAction == null) { + return; + } + + switch (defaultAction) { + case REPLY: { + messageHeaderClickListener.onMenuItemClick(R.id.reply); + break; + } + case REPLY_ALL: { + messageHeaderClickListener.onMenuItemClick(R.id.reply_all); + break; + } + default: { + throw new IllegalStateException("Unknown reply action: " + defaultAction); + } + } + } + + private void showOverflowMenu(View view) { + PopupMenu popupMenu = new PopupMenu(getContext(), view); + popupMenu.setOnMenuItemClickListener(item -> { + messageHeaderClickListener.onMenuItemClick(item.getItemId()); + return true; + }); + popupMenu.inflate(R.menu.single_message_options); + setAdditionalReplyActions(popupMenu); + popupMenu.show(); + } + @Override public boolean onLongClick(View view) { int id = view.getId(); if (id == R.id.subject) { - onAddSubjectToClipboard(mSubjectView.getText().toString()); - } else if (id == R.id.from) { - onAddAddressesToClipboard(mMessage.getFrom()); - } else if (id == R.id.to) { - onAddRecipientsToClipboard(Message.RecipientType.TO); - } else if (id == R.id.cc) { - onAddRecipientsToClipboard(Message.RecipientType.CC); + onAddSubjectToClipboard(subjectView.getText().toString()); } return true; } private void toggleSubjectViewMaxLines() { - if (mSubjectView.getMaxLines() == DEFAULT_SUBJECT_LINES) { - mSubjectView.setMaxLines(Integer.MAX_VALUE); + if (subjectView.getMaxLines() == DEFAULT_SUBJECT_LINES) { + subjectView.setMaxLines(Integer.MAX_VALUE); } else { - mSubjectView.setMaxLines(DEFAULT_SUBJECT_LINES); + subjectView.setMaxLines(DEFAULT_SUBJECT_LINES); } } private void onAddSubjectToClipboard(String subject) { + ClipboardManager clipboardManager = DI.get(ClipboardManager.class); clipboardManager.setText("subject", subject); - Toast.makeText(mContext, createMessageForSubject(), Toast.LENGTH_LONG).show(); - } - - private void onAddSenderToContacts() { - if (mMessage != null) { - try { - final Address senderEmail = mMessage.getFrom()[0]; - mContacts.createContact(senderEmail); - } catch (Exception e) { - Timber.e(e, "Couldn't create contact"); - } - } + Toast.makeText(getContext(), createMessageForSubject(), Toast.LENGTH_LONG).show(); } public String createMessageForSubject() { - return mContext.getResources().getString(R.string.copy_subject_to_clipboard); - } - - public String createMessage(int addressesCount) { - return mContext.getResources().getQuantityString(R.plurals.copy_address_to_clipboard, addressesCount); - } - - private void onAddAddressesToClipboard(Address[] addresses) { - String addressList = Address.toString(addresses); - clipboardManager.setText("addresses", addressList); - - Toast.makeText(mContext, createMessage(addresses.length), Toast.LENGTH_LONG).show(); - } - - private void onAddRecipientsToClipboard(Message.RecipientType recipientType) { - onAddAddressesToClipboard(mMessage.getRecipients(recipientType)); + return getResources().getString(R.string.copy_subject_to_clipboard); } public void setOnFlagListener(OnClickListener listener) { - mFlagged.setOnClickListener(listener); + starView.setOnClickListener(listener); } - public void populate(final Message message, final Account account, boolean showStar) { + public void populate(final Message message, final Account account, boolean showStar, boolean showAccountChip) { +/* + if (showAccountChip) { + accountChip.setVisibility(View.VISIBLE); + accountChip.setText(account.getDisplayName()); + accountChip.setChipBackgroundColor(ColorStateList.valueOf(account.getChipColor())); + } else { + accountChip.setVisibility(View.GONE); + } +*/ + accountChip.setVisibility(View.GONE); + Address fromAddress = null; Address[] fromAddresses = message.getFrom(); if (fromAddresses.length > 0) { fromAddress = fromAddresses[0]; } - final Contacts contacts = K9.isShowContactName() ? mContacts : null; - final CharSequence from = mMessageHelper.getSenderDisplayName(fromAddress); - final CharSequence to = MessageHelper.toFriendly(message.getRecipients(Message.RecipientType.TO), contacts); - final CharSequence cc = MessageHelper.toFriendly(message.getRecipients(Message.RecipientType.CC), contacts); - final CharSequence bcc = MessageHelper.toFriendly(message.getRecipients(Message.RecipientType.BCC), contacts); + if (K9.isShowContactPicture()) { + contactPictureView.setVisibility(View.VISIBLE); + if (fromAddress != null) { + ContactPictureLoader contactsPictureLoader = ContactPicture.getContactPictureLoader(); + contactsPictureLoader.setContactPicture(contactPictureView, fromAddress); + } else { + contactPictureView.setImageResource(R.drawable.ic_avatar); + } + } else { + contactPictureView.setVisibility(View.GONE); + } - mMessage = message; - mAccount = account; + CharSequence from = messageHelper.getSenderDisplayName(fromAddress); + fromView.setText(from); - if (K9.isShowContactPicture()) { - mContactBadge.setVisibility(View.VISIBLE); - mContactsPictureLoader = ContactPicture.getContactPictureLoader(); - } else { - mContactBadge.setVisibility(View.GONE); + if (showStar) { + starView.setVisibility(View.VISIBLE); + starView.setSelected(message.isSet(Flag.FLAGGED)); + } else { + starView.setVisibility(View.GONE); } - if (shouldShowSender(message)) { - mSenderView.setVisibility(VISIBLE); - String sender = getResources().getString(R.string.message_view_sender_label, - MessageHelper.toFriendly(message.getSender(), contacts)); - mSenderView.setText(sender); + if (message.getSentDate() != null) { + dateView.setText(relativeDateTimeFormatter.formatDate(message.getSentDate().getTime())); } else { - mSenderView.setVisibility(View.GONE); + dateView.setText(""); } - String dateTime = DateUtils.formatDateTime(mContext, - message.getSentDate().getTime(), - DateUtils.FORMAT_SHOW_DATE - | DateUtils.FORMAT_ABBREV_ALL - | DateUtils.FORMAT_SHOW_TIME - | DateUtils.FORMAT_SHOW_YEAR); - mDateView.setText(dateTime); + setRecipientNames(message, account); - if (K9.isShowContactPicture()) { - if (fromAddress != null) { - mContactBadge.setContact(fromAddress); - mContactsPictureLoader.setContactPicture(mContactBadge, fromAddress); - } else { - mContactBadge.setImageResource(R.drawable.ic_avatar); - } - } + setReplyActions(message, account); - mFromView.setText(from); + setVisibility(View.VISIBLE); + } - updateAddressField(mToView, to, mToLabel); - updateAddressField(mCcView, cc, mCcLabel); - updateAddressField(mBccView, bcc, mBccLabel); - mAnsweredIcon.setVisibility(message.isSet(Flag.ANSWERED) ? View.VISIBLE : View.GONE); - mForwardedIcon.setVisibility(message.isSet(Flag.FORWARDED) ? View.VISIBLE : View.GONE); + private void setRecipientNames(Message message, Account account) { + Integer contactNameColor = K9.isChangeContactNameColor() ? K9.getContactNameColor() : null; - if (showStar) { - mFlagged.setVisibility(View.VISIBLE); - mFlagged.setChecked(message.isSet(Flag.FLAGGED)); + RealContactNameProvider contactNameProvider = new RealContactNameProvider(Contacts.getInstance(getContext())); + + RealAddressFormatter addressFormatter = new RealAddressFormatter(contactNameProvider, account, + K9.isShowCorrespondentNames(), K9.isShowContactName(), contactNameColor, + getContext().getString(R.string.message_view_me_text)); + + DisplayRecipientsExtractor displayRecipientsExtractor = new DisplayRecipientsExtractor(addressFormatter, + recipientNamesView.getMaxNumberOfRecipientNames()); + + DisplayRecipients displayRecipients = displayRecipientsExtractor.extractDisplayRecipients(message, account); + + recipientNamesView.setRecipients(displayRecipients.getRecipientNames(), + displayRecipients.getNumberOfRecipients()); + } + + private void setReplyActions(Message message, Account account) { + ReplyActions replyActions = replyActionStrategy.getReplyActions(account, message); + this.replyActions = replyActions; + + setDefaultReplyAction(replyActions.getDefaultAction()); + } + + private void setDefaultReplyAction(ReplyAction defaultAction) { + if (defaultAction == null) { + menuPrimaryActionView.setVisibility(View.GONE); } else { - mFlagged.setVisibility(View.GONE); - } + int replyIconResource = getReplyImageResource(defaultAction); + menuPrimaryActionView.setImageResource(replyIconResource); - mChip.setBackgroundColor(mAccount.getChipColor()); + String replyActionName = getReplyActionName(defaultAction); + TooltipCompat.setTooltipText(menuPrimaryActionView, replyActionName); + } + } - setVisibility(View.VISIBLE); + @DrawableRes + private int getReplyImageResource(@NonNull ReplyAction replyAction) { + switch (replyAction) { + case REPLY: { + return R.drawable.ic_reply; + } + case REPLY_ALL: { + return R.drawable.ic_reply_all; + } + default: { + throw new IllegalStateException("Unknown reply action: " + replyAction); + } + } } - public void setSubject(@NonNull String subject) { - mSubjectView.setText(subject); - mSubjectView.setTextColor(0xff000000 | defaultSubjectColor); + @NonNull + private String getReplyActionName(@NonNull ReplyAction replyAction) { + Context context = getContext(); + switch (replyAction) { + case REPLY: { + return context.getString(R.string.reply_action); + } + case REPLY_ALL: { + return context.getString(R.string.reply_all_action); + } + default: { + throw new IllegalStateException("Unknown reply action: " + replyAction); + } + } } - public static boolean shouldShowSender(Message message) { - Address[] from = message.getFrom(); - Address[] sender = message.getSender(); + private void setAdditionalReplyActions(PopupMenu popupMenu) { + List additionalActions = replyActions.getAdditionalActions(); + if (!additionalActions.contains(ReplyAction.REPLY)) { + popupMenu.getMenu().removeItem(R.id.reply); + } + if (!additionalActions.contains(ReplyAction.REPLY_ALL)) { + popupMenu.getMenu().removeItem(R.id.reply_all); + } + } - return sender != null && sender.length != 0 && !Arrays.equals(from, sender); + public void setSubject(@NonNull String subject) { + subjectView.setText(subject); } public void hideCryptoStatus() { - mCryptoStatusIcon.setVisibility(View.GONE); + cryptoStatusIcon.setVisibility(View.GONE); } public void setCryptoStatusLoading() { @@ -319,37 +346,13 @@ public class MessageHeader extends LinearLayout implements OnClickListener, OnLo private void setCryptoDisplayStatus(MessageCryptoDisplayStatus displayStatus) { int color = ThemeUtils.getStyledColor(getContext(), displayStatus.getColorAttr()); - mCryptoStatusIcon.setEnabled(displayStatus.isEnabled()); - mCryptoStatusIcon.setVisibility(View.VISIBLE); - mCryptoStatusIcon.setImageResource(displayStatus.getStatusIconRes()); - mCryptoStatusIcon.setColorFilter(color); - } - - private void updateAddressField(TextView v, CharSequence text, View label) { - boolean hasText = !TextUtils.isEmpty(text); - v.setText(text + " "); - v.setVisibility(hasText ? View.VISIBLE : View.GONE); - label.setVisibility(hasText ? View.VISIBLE : View.GONE); - } - - /** - * Expand or collapse a TextView by removing or adding the 2 lines limitation - */ - private void expand(TextView v, boolean expand) { - if (expand) { - v.setMaxLines(Integer.MAX_VALUE); - v.setEllipsize(null); - } else { - v.setMaxLines(2); - v.setEllipsize(android.text.TextUtils.TruncateAt.END); - } - } - - public void setOnCryptoClickListener(OnCryptoClickListener onCryptoClickListener) { - this.onCryptoClickListener = onCryptoClickListener; + cryptoStatusIcon.setEnabled(displayStatus.isEnabled()); + cryptoStatusIcon.setVisibility(View.VISIBLE); + cryptoStatusIcon.setImageResource(displayStatus.getStatusIconRes()); + cryptoStatusIcon.setColorFilter(color); } - public void setOnMenuItemClickListener(OnMenuItemClickListener onMenuItemClickListener) { - this.onMenuItemClickListener = onMenuItemClickListener; + public void setMessageHeaderClickListener(MessageHeaderClickListener messageHeaderClickListener) { + this.messageHeaderClickListener = messageHeaderClickListener; } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/view/RecipientSelectView.java b/app/ui/legacy/src/main/java/com/fsck/k9/view/RecipientSelectView.java index 9a160a5edc3bcf729fb8442fdaf8e9358b336f5c..ea9118b7abad582d191c92381e543bf0840cc7f4 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/view/RecipientSelectView.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/view/RecipientSelectView.java @@ -184,10 +184,6 @@ public class RecipientSelectView extends TokenCompleteTextView implem return new Recipient(parsedAddresses[0]); } - public boolean isEmpty() { - return getObjects().isEmpty(); - } - public void setLoaderManager(@Nullable LoaderManager loaderManager) { this.loaderManager = loaderManager; } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/view/ToolableViewAnimator.java b/app/ui/legacy/src/main/java/com/fsck/k9/view/ToolableViewAnimator.java index 4968c8a1c1237c53ebd801e387813ec9400742ec..2f0be520f4d500ca3c5f6a5ed9fc039859df3446 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/view/ToolableViewAnimator.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/view/ToolableViewAnimator.java @@ -31,7 +31,6 @@ import androidx.annotation.NonNull; import android.util.AttributeSet; import android.view.View; import android.view.ViewGroup; -import android.view.animation.Animation; import android.widget.ViewAnimator; import com.fsck.k9.ui.R; @@ -82,23 +81,6 @@ public class ToolableViewAnimator extends ViewAnimator { } } - public void setDisplayedChild(int whichChild, boolean animate) { - if (animate) { - setDisplayedChild(whichChild); - return; - } - - Animation savedInAnim = getInAnimation(); - Animation savedOutAnim = getOutAnimation(); - setInAnimation(null); - setOutAnimation(null); - - setDisplayedChild(whichChild); - - setInAnimation(savedInAnim); - setOutAnimation(savedOutAnim); - } - public void setDisplayedChildId(int id) { if (getDisplayedChildId() == id) { return; diff --git a/app/ui/legacy/src/main/res/drawable/btn_select_star.xml b/app/ui/legacy/src/main/res/drawable/btn_select_star.xml new file mode 100644 index 0000000000000000000000000000000000000000..dbab9d79d2837e66af34c2b29b4fe3fee8fc0dd3 --- /dev/null +++ b/app/ui/legacy/src/main/res/drawable/btn_select_star.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/ui/legacy/src/main/res/drawable/dots_vertical.xml b/app/ui/legacy/src/main/res/drawable/dots_vertical.xml new file mode 100644 index 0000000000000000000000000000000000000000..b4c72b831e55af404ef9d2a63330516ff7fd14dd --- /dev/null +++ b/app/ui/legacy/src/main/res/drawable/dots_vertical.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/ui/legacy/src/main/res/drawable/ic_person_plus.xml b/app/ui/legacy/src/main/res/drawable/ic_person_add.xml similarity index 100% rename from app/ui/legacy/src/main/res/drawable/ic_person_plus.xml rename to app/ui/legacy/src/main/res/drawable/ic_person_add.xml diff --git a/app/ui/legacy/src/main/res/drawable/ic_reply.xml b/app/ui/legacy/src/main/res/drawable/ic_reply.xml new file mode 100644 index 0000000000000000000000000000000000000000..bd35ee9e7e17e905c88939c0b40ffe4616c17051 --- /dev/null +++ b/app/ui/legacy/src/main/res/drawable/ic_reply.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/ui/legacy/src/main/res/layout/message.xml b/app/ui/legacy/src/main/res/layout/message.xml index c2e0590901b431d90b35728b305d33b27266b487..81f3b7274412c8c7ea8498905d3167169d9cb76e 100644 --- a/app/ui/legacy/src/main/res/layout/message.xml +++ b/app/ui/legacy/src/main/res/layout/message.xml @@ -2,13 +2,13 @@ -