diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ee9ba03c0ff60d1bb6bbf9042bbc451366397f87..ac3fb49212767076c57e9b457e103a7acf8a198c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: "registry.gitlab.e.foundation:5000/e/apps/docker-android-apps-cicd:legacy" +image: "registry.gitlab.e.foundation:5000/e/apps/docker-android-apps-cicd:latest" stages: - build diff --git a/app/autodiscovery/providersxml/src/main/res/xml/providers.xml b/app/autodiscovery/providersxml/src/main/res/xml/providers.xml index 1cd6380a958d4e99848bef769a25ed3d60ef36fc..4abee9f5ec97803533d2adda4b0c67bac7383a92 100644 --- a/app/autodiscovery/providersxml/src/main/res/xml/providers.xml +++ b/app/autodiscovery/providersxml/src/main/res/xml/providers.xml @@ -294,6 +294,16 @@ + + + + + + + + + + diff --git a/app/core/src/main/AndroidManifest.xml b/app/core/src/main/AndroidManifest.xml index 5900dccc24b0a8a8f7c086fc78dae3c83108a1b5..dca0147e8479133a5bf48a146db018e513b7eed9 100644 --- a/app/core/src/main/AndroidManifest.xml +++ b/app/core/src/main/AndroidManifest.xml @@ -7,11 +7,21 @@ + + tools:node="merge"> + + + + + + diff --git a/app/core/src/main/java/com/fsck/k9/Account.java b/app/core/src/main/java/com/fsck/k9/Account.java index 5fabbf671688ee7e15dd25f8f922efd5e08132d8..5cf39c0ac14e6a9165ca0da39c9427d22adb221c 100644 --- a/app/core/src/main/java/com/fsck/k9/Account.java +++ b/app/core/src/main/java/com/fsck/k9/Account.java @@ -40,12 +40,9 @@ public class Account implements BaseAccount { public ExpungePolicy toBackendExpungePolicy() { switch (this) { - case EXPUNGE_IMMEDIATELY: - return ExpungePolicy.IMMEDIATELY; - case EXPUNGE_MANUALLY: - return ExpungePolicy.MANUALLY; - case EXPUNGE_ON_POLL: - return ExpungePolicy.ON_POLL; + case EXPUNGE_IMMEDIATELY: return ExpungePolicy.IMMEDIATELY; + case EXPUNGE_MANUALLY: return ExpungePolicy.MANUALLY; + case EXPUNGE_ON_POLL: return ExpungePolicy.ON_POLL; } throw new AssertionError("Unhandled case: " + this.name()); @@ -65,7 +62,7 @@ public class Account implements BaseAccount { } public static DeletePolicy fromInt(int initialSetting) { - for (DeletePolicy policy : values()) { + for (DeletePolicy policy: values()) { if (policy.setting == initialSetting) { return policy; } @@ -109,7 +106,8 @@ public class Account implements BaseAccount { private ServerSettings outgoingServerSettings; /** - * Storage provider ID, used to locate and manage the underlying DB/file storage + * Storage provider ID, used to locate and manage the underlying DB/file + * storage */ private String localStorageProviderId; private String description; @@ -190,7 +188,7 @@ public class Account implements BaseAccount { /** * Database ID of the folder that was last selected for a copy or move operation. - *

+ * * Note: For now this value isn't persisted. So it will be reset when K-9 Mail is restarted. */ private Long lastSelectedFolderId = null; @@ -722,7 +720,7 @@ public class Account implements BaseAccount { @Override public boolean equals(Object o) { if (o instanceof Account) { - return ((Account) o).accountUuid.equals(accountUuid); + return ((Account)o).accountUuid.equals(accountUuid); } return super.equals(o); } @@ -824,24 +822,22 @@ public class Account implements BaseAccount { now.set(Calendar.MILLISECOND, 0); if (age < 28) { now.add(Calendar.DATE, age * -1); - } else { - switch (age) { - case 28: - now.add(Calendar.MONTH, -1); - break; - case 56: - now.add(Calendar.MONTH, -2); - break; - case 84: - now.add(Calendar.MONTH, -3); - break; - case 168: - now.add(Calendar.MONTH, -6); - break; - case 365: - now.add(Calendar.YEAR, -1); - break; - } + } else switch (age) { + case 28: + now.add(Calendar.MONTH, -1); + break; + case 56: + now.add(Calendar.MONTH, -2); + break; + case 84: + now.add(Calendar.MONTH, -3); + break; + case 168: + now.add(Calendar.MONTH, -6); + break; + case 365: + now.add(Calendar.YEAR, -1); + break; } return now.getTime(); @@ -1058,7 +1054,6 @@ public class Account implements BaseAccount { public synchronized void setAlwaysShowCcBcc(boolean show) { alwaysShowCcBcc = show; } - public boolean isRemoteSearchFullText() { return false; // Temporarily disabled //return remoteSearchFullText; diff --git a/app/core/src/main/java/com/fsck/k9/AccountPreferenceSerializer.kt b/app/core/src/main/java/com/fsck/k9/AccountPreferenceSerializer.kt index 1602420bf44dfeba1981833d36bbd744e9d3db56..8a49ebf535d9e95f19502565341cc690813a92be 100644 --- a/app/core/src/main/java/com/fsck/k9/AccountPreferenceSerializer.kt +++ b/app/core/src/main/java/com/fsck/k9/AccountPreferenceSerializer.kt @@ -51,6 +51,7 @@ class AccountPreferenceSerializer( folderNotifyNewMailMode = getEnumStringPref(storage, "$accountUuid.folderNotifyNewMailMode", FolderMode.ALL) isNotifySelfNewMail = storage.getBoolean("$accountUuid.notifySelfNewMail", true) isNotifyContactsMailOnly = storage.getBoolean("$accountUuid.notifyContactsMailOnly", false) + isIgnoreChatMessages = storage.getBoolean("$accountUuid.ignoreChatMessages", false) isNotifySync = storage.getBoolean("$accountUuid.notifyMailCheck", false) isIgnoreChatMessages = storage.getBoolean("$accountUuid.ignoreChatMessages", false) deletePolicy = DeletePolicy.fromInt(storage.getInt("$accountUuid.deletePolicy", DeletePolicy.NEVER.setting)) diff --git a/app/core/src/main/java/com/fsck/k9/Clock.java b/app/core/src/main/java/com/fsck/k9/Clock.java deleted file mode 100644 index a07841144e285579836a013843cbe2d867b87e4e..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/Clock.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (C) 2010 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.fsck.k9; - -/** - * A class provide the current time (like {@link System#currentTimeMillis()}). - * It's intended to be mocked out for unit tests. - */ -public class Clock { - public static final Clock INSTANCE = new Clock(); - - protected Clock() { - } - - public long getTime() { - return System.currentTimeMillis(); - } -} diff --git a/app/core/src/main/java/com/fsck/k9/Clock.kt b/app/core/src/main/java/com/fsck/k9/Clock.kt new file mode 100644 index 0000000000000000000000000000000000000000..755102351c07ac40ef80d2d6c408d655d8c645c5 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/Clock.kt @@ -0,0 +1,13 @@ +package com.fsck.k9 + +/** + * An interface to provide the current time. + */ +interface Clock { + val time: Long +} + +internal class RealClock : Clock { + override val time: Long + get() = System.currentTimeMillis() +} 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 de460da656119ce617fef30b1c02811c82dff641..d79482d55df87d0417276d70c7e5389a7e58027b 100644 --- a/app/core/src/main/java/com/fsck/k9/CoreKoinModules.kt +++ b/app/core/src/main/java/com/fsck/k9/CoreKoinModules.kt @@ -6,12 +6,14 @@ import com.fsck.k9.controller.push.controllerPushModule import com.fsck.k9.crypto.openPgpModule import com.fsck.k9.helper.helperModule import com.fsck.k9.job.jobModule +import com.fsck.k9.logging.loggingModule import com.fsck.k9.mailstore.mailStoreModule import com.fsck.k9.message.extractors.extractorModule import com.fsck.k9.message.html.htmlModule import com.fsck.k9.message.quote.quoteModule 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 @@ -30,5 +32,7 @@ val coreModules = listOf( jobModule, helperModule, preferencesModule, - connectivityModule + connectivityModule, + powerModule, + loggingModule ) diff --git a/app/core/src/main/java/com/fsck/k9/CoreResourceProvider.kt b/app/core/src/main/java/com/fsck/k9/CoreResourceProvider.kt index 097221557bd17d58ef79c47cc51abb688dab4f11..ca191b099760b1d66e7c6f440fbb79f301a0b8ae 100644 --- a/app/core/src/main/java/com/fsck/k9/CoreResourceProvider.kt +++ b/app/core/src/main/java/com/fsck/k9/CoreResourceProvider.kt @@ -6,9 +6,6 @@ interface CoreResourceProvider { fun defaultSignature(): String fun defaultIdentityDescription(): String - fun internalStorageProviderName(): String - fun externalStorageProviderName(): String - fun contactDisplayNamePrefix(): String fun contactUnknownSender(): String fun contactUnknownRecipient(): String diff --git a/app/core/src/main/java/com/fsck/k9/K9.kt b/app/core/src/main/java/com/fsck/k9/K9.kt index 9e176d6cd0faba1121e36f14b7a17ce03d5c50da..d26cc5c66bc74d7a68f91c45399ee39d062a57f3 100644 --- a/app/core/src/main/java/com/fsck/k9/K9.kt +++ b/app/core/src/main/java/com/fsck/k9/K9.kt @@ -155,7 +155,7 @@ object K9 : EarlyInit { @JvmStatic var isConfirmMarkAllRead = true - @JvmStatic + @JvmStatic var notificationQuickDeleteBehaviour = NotificationQuickDelete.ALWAYS @JvmStatic @@ -271,7 +271,8 @@ object K9 : EarlyInit { return false } - val quietTimeChecker = QuietTimeChecker(Clock.INSTANCE, quietTimeStarts, quietTimeEnds) + val clock = DI.get() + val quietTimeChecker = QuietTimeChecker(clock, quietTimeStarts, quietTimeEnds) return quietTimeChecker.isQuietTime } diff --git a/app/core/src/main/java/com/fsck/k9/KoinModule.kt b/app/core/src/main/java/com/fsck/k9/KoinModule.kt index 4537ac7dcba770e12cdc214053d30c9e46d83503..05c30287cc1146b04577fcce4ad85c4829eafb72 100644 --- a/app/core/src/main/java/com/fsck/k9/KoinModule.kt +++ b/app/core/src/main/java/com/fsck/k9/KoinModule.kt @@ -2,13 +2,11 @@ package com.fsck.k9 import android.content.Context import com.fsck.k9.helper.Contacts -import com.fsck.k9.mail.power.PowerManager import com.fsck.k9.mail.ssl.DefaultTrustedSocketFactory import com.fsck.k9.mail.ssl.LocalKeyStore import com.fsck.k9.mail.ssl.TrustManagerFactory import com.fsck.k9.mail.ssl.TrustedSocketFactory import com.fsck.k9.mailstore.LocalStoreProvider -import com.fsck.k9.power.TracingPowerManager import com.fsck.k9.setup.ServerNameSuggester import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.GlobalScope @@ -27,13 +25,12 @@ val mainModule = module { single { get().resources } single { get().contentResolver } single { LocalStoreProvider() } - single { TracingPowerManager.getPowerManager(get()) } single { Contacts.getInstance(get()) } single { LocalKeyStore(directoryProvider = get()) } single { TrustManagerFactory.createInstance(get()) } single { LocalKeyStoreManager(get()) } single { DefaultTrustedSocketFactory(get(), get()) } - single { Clock.INSTANCE } + single { RealClock() } factory { ServerNameSuggester() } factory { EmailAddressValidator() } factory { ServerSettingsSerializer() } diff --git a/app/core/src/main/java/com/fsck/k9/Preferences.kt b/app/core/src/main/java/com/fsck/k9/Preferences.kt index 549d59e7f973ed156e14bdf639b7b7624bc8c2ec..44549db552d32ee7b5d8221c681ef08ec267fb63 100644 --- a/app/core/src/main/java/com/fsck/k9/Preferences.kt +++ b/app/core/src/main/java/com/fsck/k9/Preferences.kt @@ -3,6 +3,7 @@ package com.fsck.k9 import android.content.Context import androidx.annotation.GuardedBy import androidx.annotation.RestrictTo +import com.fsck.k9.helper.sendBlockingSilently import com.fsck.k9.mail.MessagingException import com.fsck.k9.mailstore.LocalStoreProvider import com.fsck.k9.preferences.AccountManager @@ -115,8 +116,13 @@ class Preferences internal constructor( @OptIn(ExperimentalCoroutinesApi::class) override fun getAccountFlow(accountUuid: String): Flow { - return callbackFlow { - val initialAccount = getAccount(accountUuid) ?: return@callbackFlow + return callbackFlow { + val initialAccount = getAccount(accountUuid) + if (initialAccount == null) { + close() + return@callbackFlow + } + send(initialAccount) val listener = AccountsChangeListener { @@ -124,7 +130,7 @@ class Preferences internal constructor( if (account != null) { sendBlockingSilently(account) } else { - channel.close() + close() } } addOnAccountsChangeListener(listener) diff --git a/app/core/src/main/java/com/fsck/k9/controller/MemorizingMessagingListener.java b/app/core/src/main/java/com/fsck/k9/controller/MemorizingMessagingListener.java index 08d93e459c46e937271e3d302bf798336ecbcf80..20d94a2ef0ab774a7074065c32317bd098cea75e 100644 --- a/app/core/src/main/java/com/fsck/k9/controller/MemorizingMessagingListener.java +++ b/app/core/src/main/java/com/fsck/k9/controller/MemorizingMessagingListener.java @@ -102,7 +102,7 @@ class MemorizingMessagingListener extends SimpleMessagingListener { } private static String getMemoryKey(Account account, long folderId) { - return account.getDescription() + ":" + folderId; + return account.getUuid() + ":" + folderId; } private enum MemorizingState { STARTED, FINISHED, FAILED } 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 1d3764ce6a9be5a81eabd873fb01cf33f71380f4..4cfe1e1d590c4b32a8efb508e095bb4ffa43c1cb 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 @@ -23,7 +23,6 @@ internal class DefaultMessageCountsProvider( private val localStoreProvider: LocalStoreProvider ) : MessageCountsProvider { override fun getMessageCounts(account: Account): MessageCounts { - return try { val localStore = localStoreProvider.getInstance(account) @@ -62,4 +61,4 @@ internal class DefaultMessageCountsProvider( MessageCounts(0, 0) } } -} \ No newline at end of file +} diff --git a/app/core/src/main/java/com/fsck/k9/controller/MessageReference.java b/app/core/src/main/java/com/fsck/k9/controller/MessageReference.java deleted file mode 100644 index 15701f5cae340f1f7795c6217924735d6665d2b0..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/controller/MessageReference.java +++ /dev/null @@ -1,139 +0,0 @@ -package com.fsck.k9.controller; - - -import java.util.StringTokenizer; - -import androidx.annotation.Nullable; - -import com.fsck.k9.mail.Flag; -import com.fsck.k9.mail.filter.Base64; - -import org.jetbrains.annotations.NotNull; - -import static com.fsck.k9.helper.Preconditions.checkNotNull; - - -public class MessageReference { - private static final char IDENTITY_VERSION_2 = '#'; - private static final String IDENTITY_SEPARATOR = ":"; - - - private final String accountUuid; - private final long folderId; - private final String uid; - private final Flag flag; - - - @Nullable - public static MessageReference parse(String identity) { - if (identity == null || identity.length() < 1 || identity.charAt(0) != IDENTITY_VERSION_2) { - return null; - } - - StringTokenizer tokens = new StringTokenizer(identity.substring(2), IDENTITY_SEPARATOR, false); - if (tokens.countTokens() < 3) { - return null; - } - - String accountUuid = Base64.decode(tokens.nextToken()); - long folderId = Long.parseLong(Base64.decode(tokens.nextToken())); - String uid = Base64.decode(tokens.nextToken()); - - if (!tokens.hasMoreTokens()) { - return new MessageReference(accountUuid, folderId, uid, null); - } - - Flag flag; - try { - flag = Flag.valueOf(tokens.nextToken()); - } catch (IllegalArgumentException e) { - return null; - } - - return new MessageReference(accountUuid, folderId, uid, flag); - } - - public MessageReference(String accountUuid, long folderId, String uid, Flag flag) { - this.accountUuid = checkNotNull(accountUuid); - this.folderId = folderId; - this.uid = checkNotNull(uid); - this.flag = flag; - } - - public String toIdentityString() { - StringBuilder refString = new StringBuilder(); - - refString.append(IDENTITY_VERSION_2); - refString.append(IDENTITY_SEPARATOR); - refString.append(Base64.encode(accountUuid)); - refString.append(IDENTITY_SEPARATOR); - refString.append(Base64.encode(Long.toString(folderId))); - refString.append(IDENTITY_SEPARATOR); - refString.append(Base64.encode(uid)); - if (flag != null) { - refString.append(IDENTITY_SEPARATOR); - refString.append(flag.name()); - } - - return refString.toString(); - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof MessageReference)) { - return false; - } - MessageReference other = (MessageReference) o; - return equals(other.accountUuid, other.folderId, other.uid); - } - - public boolean equals(String accountUuid, long folderId, String uid) { - return this.accountUuid.equals(accountUuid) && this.folderId == folderId && this.uid.equals(uid); - } - - @Override - public int hashCode() { - final int MULTIPLIER = 31; - - int result = 1; - result = MULTIPLIER * result + accountUuid.hashCode(); - result = MULTIPLIER * result + (int) (folderId ^ (folderId >>> 32)); - result = MULTIPLIER * result + uid.hashCode(); - return result; - } - - @NotNull - @Override - public String toString() { - return "MessageReference{" + - "accountUuid='" + accountUuid + '\'' + - ", folderId='" + folderId + '\'' + - ", uid='" + uid + '\'' + - ", flag=" + flag + - '}'; - } - - public String getAccountUuid() { - return accountUuid; - } - - public long getFolderId() { - return folderId; - } - - public String getUid() { - return uid; - } - - public Flag getFlag() { - return flag; - } - - public MessageReference withModifiedUid(String newUid) { - return new MessageReference(accountUuid, folderId, newUid, flag); - } - - public MessageReference withModifiedFlag(Flag newFlag) { - return new MessageReference(accountUuid, folderId, uid, newFlag); - } -} diff --git a/app/core/src/main/java/com/fsck/k9/controller/MessageReference.kt b/app/core/src/main/java/com/fsck/k9/controller/MessageReference.kt new file mode 100644 index 0000000000000000000000000000000000000000..13ae584a8c8c483784a9702d83c6623436634756 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/controller/MessageReference.kt @@ -0,0 +1,52 @@ +package com.fsck.k9.controller + +import com.fsck.k9.mail.filter.Base64 +import java.util.StringTokenizer + +data class MessageReference( + val accountUuid: String, + val folderId: Long, + val uid: String +) { + fun toIdentityString(): String { + return buildString { + append(IDENTITY_VERSION_2) + append(IDENTITY_SEPARATOR) + append(Base64.encode(accountUuid)) + append(IDENTITY_SEPARATOR) + append(Base64.encode(folderId.toString())) + append(IDENTITY_SEPARATOR) + append(Base64.encode(uid)) + } + } + + fun equals(accountUuid: String, folderId: Long, uid: String): Boolean { + return this.accountUuid == accountUuid && this.folderId == folderId && this.uid == uid + } + + fun withModifiedUid(newUid: String): MessageReference { + return copy(uid = newUid) + } + + companion object { + private const val IDENTITY_VERSION_2 = '#' + private const val IDENTITY_SEPARATOR = ":" + + @JvmStatic + fun parse(identity: String?): MessageReference? { + if (identity == null || identity.isEmpty() || identity[0] != IDENTITY_VERSION_2) { + return null + } + + val tokens = StringTokenizer(identity.substring(2), IDENTITY_SEPARATOR, false) + if (tokens.countTokens() < 3) { + return null + } + + val accountUuid = Base64.decode(tokens.nextToken()) + val folderId = Base64.decode(tokens.nextToken()).toLong() + val uid = Base64.decode(tokens.nextToken()) + return MessageReference(accountUuid, folderId, uid) + } + } +} 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 adae60ae2d7baef62e497e8a6041ce423525846b..40905a6c51caf4f1bd5c947a67541dea0c662104 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 @@ -23,7 +23,6 @@ import java.util.concurrent.atomic.AtomicInteger; import android.annotation.SuppressLint; import android.content.Context; -import android.os.PowerManager; import android.os.Process; import android.os.SystemClock; @@ -65,6 +64,8 @@ import com.fsck.k9.mail.MessageRetrievalListener; import com.fsck.k9.mail.MessagingException; import com.fsck.k9.mail.Part; import com.fsck.k9.mail.ServerSettings; +import com.fsck.k9.mail.power.PowerManager; +import com.fsck.k9.mail.power.WakeLock; import com.fsck.k9.mailstore.FolderDetailsAccessor; import com.fsck.k9.mailstore.LocalFolder; import com.fsck.k9.mailstore.LocalMessage; @@ -79,8 +80,6 @@ import com.fsck.k9.mailstore.SaveMessageDataCreator; import com.fsck.k9.mailstore.SendState; import com.fsck.k9.notification.NotificationController; import com.fsck.k9.notification.NotificationStrategy; -import com.fsck.k9.power.TracingPowerManager; -import com.fsck.k9.power.TracingPowerManager.TracingWakeLock; import com.fsck.k9.search.LocalSearch; import com.fsck.k9.search.SearchAccount; import org.jetbrains.annotations.NotNull; @@ -95,17 +94,22 @@ import static com.fsck.k9.search.LocalSearchExtensions.getAccountsFromLocalSearc /** - * Starts a long running (application) Thread that will run through commands that require remote mailbox access. This - * class is used to serialize and prioritize these commands. Each method that will submit a command requires a - * MessagingListener instance to be provided. It is expected that that listener has also been added as a registered - * listener using addListener(). When a command is to be executed, if the listener that was provided with the command is - * no longer registered the command is skipped. The design idea for the above is that when an Activity starts it - * registers as a listener. When it is paused it removes itself. Thus, any commands that that activity submitted are + * Starts a long running (application) Thread that will run through commands + * that require remote mailbox access. This class is used to serialize and + * prioritize these commands. Each method that will submit a command requires a + * MessagingListener instance to be provided. It is expected that that listener + * has also been added as a registered listener using addListener(). When a + * command is to be executed, if the listener that was provided with the command + * is no longer registered the command is skipped. The design idea for the above + * is that when an Activity starts it registers as a listener. When it is paused + * it removes itself. Thus, any commands that that activity submitted are * removed from the queue once the activity is no longer active. */ public class MessagingController { public static final Set SYNC_FLAGS = EnumSet.of(Flag.SEEN, Flag.FLAGGED, Flag.ANSWERED, Flag.FORWARDED); + private static final long FOLDER_LIST_STALENESS_THRESHOLD = 30 * 60 * 1000L; + private final Context context; private final NotificationController notificationController; private final NotificationStrategy notificationStrategy; @@ -114,20 +118,26 @@ public class MessagingController { private final Preferences preferences; private final MessageStoreManager messageStoreManager; private final SaveMessageDataCreator saveMessageDataCreator; + private final Thread controllerThread; + private final BlockingQueue queuedCommands = new PriorityBlockingQueue<>(); 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 MessagingListener checkMailListener = null; private volatile boolean stopped = false; + public static MessagingController getInstance(Context context) { return DI.get(MessagingController.class); } + MessagingController(Context context, NotificationController notificationController, NotificationStrategy notificationStrategy, LocalStoreProvider localStoreProvider, MessageCountsProvider messageCountsProvider, BackendManager backendManager, @@ -142,6 +152,7 @@ public class MessagingController { this.preferences = preferences; this.messageStoreManager = messageStoreManager; this.saveMessageDataCreator = saveMessageDataCreator; + controllerThread = new Thread(new Runnable() { @Override public void run() { @@ -151,7 +162,9 @@ public class MessagingController { controllerThread.setName("MessagingController"); controllerThread.start(); addListener(memorizingMessagingListener); + initializeControllerExtensions(controllerExtensions); + draftOperations = new DraftOperations(this, messageStoreManager, saveMessageDataCreator); } @@ -159,6 +172,7 @@ public class MessagingController { if (controllerExtensions.isEmpty()) { return; } + ControllerInternals internals = new ControllerInternals() { @Override public void put(@NotNull String description, @Nullable MessagingListener listener, @@ -172,6 +186,7 @@ public class MessagingController { MessagingController.this.putBackground(description, listener, runnable); } }; + for (ControllerExtension extension : controllerExtensions) { extension.init(this, backendManager, internals); } @@ -190,14 +205,16 @@ public class MessagingController { String commandDescription = null; try { final Command command = queuedCommands.take(); + if (command != null) { commandDescription = command.description; + Timber.i("Running command '%s', seq = %s (%s priority)", command.description, command.sequence, command.isForegroundPriority ? "foreground" : "background"); - command.runnable.run(); + command.runnable.run(); Timber.i(" Command '%s' completed", command.description); } @@ -248,9 +265,13 @@ public class MessagingController { } } - private String getFolderServerId(Account account, long folderId) throws MessagingException { - LocalStore localStore = getLocalStoreOrThrow(account); - return localStore.getFolderServerId(folderId); + private String getFolderServerId(Account account, long folderId) { + MessageStore messageStore = messageStoreManager.getMessageStore(account); + String folderServerId = messageStore.getFolderServerId(folderId); + if (folderServerId == null) { + throw new IllegalStateException("Folder not found (ID: " + folderId + ")"); + } + return folderServerId; } private long getFolderId(Account account, String folderServerId) { @@ -281,15 +302,19 @@ public class MessagingController { return listeners; } + public Set getListeners(MessagingListener listener) { if (listener == null) { return listeners; } + Set listeners = new HashSet<>(this.listeners); listeners.add(listener); return listeners; + } + private void suppressMessages(Account account, List messages) { EmailProviderCache cache = EmailProviderCache.getCache(account.getUuid(), context); cache.hideMessages(messages); @@ -303,12 +328,14 @@ public class MessagingController { public boolean isMessageSuppressed(LocalMessage message) { long messageId = message.getDatabaseId(); long folderId = message.getFolder().getDatabaseId(); + EmailProviderCache cache = EmailProviderCache.getCache(message.getFolder().getAccountUuid(), context); return cache.isMessageHidden(messageId, folderId); } private void setFlagInCache(final Account account, final List messageIds, final Flag flag, final boolean newState) { + EmailProviderCache cache = EmailProviderCache.getCache(account.getUuid(), context); String columnName = LocalStore.getColumnNameForFlag(flag); String value = Integer.toString((newState) ? 1 : 0); @@ -317,6 +344,7 @@ public class MessagingController { private void removeFlagFromCache(final Account account, final List messageIds, final Flag flag) { + EmailProviderCache cache = EmailProviderCache.getCache(account.getUuid(), context); String columnName = LocalStore.getColumnNameForFlag(flag); cache.removeValueForMessages(messageIds, columnName); @@ -324,6 +352,7 @@ public class MessagingController { private void setFlagForThreadsInCache(final Account account, final List threadRootIds, final Flag flag, final boolean newState) { + EmailProviderCache cache = EmailProviderCache.getCache(account.getUuid(), context); String columnName = LocalStore.getColumnNameForFlag(flag); String value = Integer.toString((newState) ? 1 : 0); @@ -332,6 +361,7 @@ public class MessagingController { private void removeFlagForThreadsFromCache(final Account account, final List messageIds, final Flag flag) { + EmailProviderCache cache = EmailProviderCache.getCache(account.getUuid(), context); String columnName = LocalStore.getColumnNameForFlag(flag); cache.removeValueForThreads(messageIds, columnName); @@ -348,10 +378,13 @@ public class MessagingController { handleAuthenticationFailure(account, true); return; } + Backend backend = getBackend(account); backend.refreshFolderList(); + long now = System.currentTimeMillis(); Timber.d("Folder list successfully refreshed @ %tc", now); + account.setLastFolderListRefreshTime(now); preferences.saveAccount(account); } catch (Exception e) { @@ -374,7 +407,9 @@ public class MessagingController { @VisibleForTesting void searchLocalMessagesSynchronous(final LocalSearch search, final MessagingListener listener) { List searchAccounts = getAccountsFromLocalSearch(search, preferences); + for (final Account account : searchAccounts) { + // Collecting statistics of the search result MessageRetrievalListener retrievalListener = new MessageRetrievalListener() { @Override @@ -389,6 +424,7 @@ public class MessagingController { public void messageFinished(LocalMessage message, int number, int ofTotal) { if (!isMessageSuppressed(message)) { List messages = new ArrayList<>(); + messages.add(message); if (listener != null) { listener.listLocalMessagesAddMessages(account, null, messages); @@ -396,6 +432,7 @@ public class MessagingController { } } }; + // build and do the query in the localstore try { LocalStore localStore = localStoreProvider.getInstance(account); @@ -404,6 +441,7 @@ public class MessagingController { Timber.e(e); } } + if (listener != null) { listener.listLocalMessagesFinished(); } @@ -412,6 +450,7 @@ public class MessagingController { public Future searchRemoteMessages(String acctUuid, long folderId, String query, Set requiredFlags, Set forbiddenFlags, MessagingListener listener) { Timber.i("searchRemoteMessages (acct = %s, folderId = %d, query = %s)", acctUuid, folderId, query); + return threadPool.submit(() -> searchRemoteMessagesSynchronous(acctUuid, folderId, query, requiredFlags, forbiddenFlags, listener) ); @@ -420,35 +459,47 @@ public class MessagingController { @VisibleForTesting void searchRemoteMessagesSynchronous(String acctUuid, long folderId, String query, Set requiredFlags, Set forbiddenFlags, MessagingListener listener) { + Account account = preferences.getAccount(acctUuid); + if (listener != null) { listener.remoteSearchStarted(folderId); } + List extraResults = new ArrayList<>(); try { LocalStore localStore = localStoreProvider.getInstance(account); + LocalFolder localFolder = localStore.getFolder(folderId); if (!localFolder.exists()) { throw new MessagingException("Folder not found"); } + localFolder.open(); String folderServerId = localFolder.getServerId(); + Backend backend = getBackend(account); + boolean performFullTextSearch = account.isRemoteSearchFullText(); List messageServerIds = backend.search(folderServerId, query, requiredFlags, forbiddenFlags, performFullTextSearch); + Timber.i("Remote search got %d results", messageServerIds.size()); + // There's no need to fetch messages already completely downloaded messageServerIds = localFolder.extractNewMessages(messageServerIds); + if (listener != null) { listener.remoteSearchServerQueryComplete(folderId, messageServerIds.size(), account.getRemoteSearchNumResults()); } + int resultLimit = account.getRemoteSearchNumResults(); if (resultLimit > 0 && messageServerIds.size() > resultLimit) { extraResults = messageServerIds.subList(resultLimit, messageServerIds.size()); messageServerIds = messageServerIds.subList(0, resultLimit); } + loadSearchResultsSynchronous(account, messageServerIds, localFolder); } catch (Exception e) { if (Thread.currentThread().isInterrupted()) { @@ -465,6 +516,7 @@ public class MessagingController { listener.remoteSearchFinished(folderId, 0, account.getRemoteSearchNumResults(), extraResults); } } + } public void loadSearchResults(Account account, long folderId, List messageServerIds, @@ -473,13 +525,16 @@ public class MessagingController { if (listener != null) { listener.enableProgressIndicator(true); } + try { LocalStore localStore = localStoreProvider.getInstance(account); LocalFolder localFolder = localStore.getFolder(folderId); if (!localFolder.exists()) { throw new MessagingException("Folder not found"); } + localFolder.open(); + loadSearchResultsSynchronous(account, messageServerIds, localFolder); } catch (MessagingException e) { Timber.e(e, "Exception in loadSearchResults"); @@ -493,16 +548,20 @@ public class MessagingController { private void loadSearchResultsSynchronous(Account account, List messageServerIds, LocalFolder localFolder) throws MessagingException { + Backend backend = getBackend(account); String folderServerId = localFolder.getServerId(); + for (String messageServerId : messageServerIds) { LocalMessage localMessage = localFolder.getMessage(messageServerId); + if (localMessage == null) { backend.downloadMessageStructure(folderServerId, messageServerId); } } } + public void loadMoreMessages(Account account, long folderId, MessagingListener listener) { try { LocalStore localStore = localStoreProvider.getInstance(account); @@ -527,6 +586,7 @@ public class MessagingController { public void synchronizeMailboxBlocking(Account account, String folderServerId) { long folderId = getFolderId(account, folderServerId); + final CountDownLatch latch = new CountDownLatch(1); putBackground("synchronizeMailbox", null, () -> { try { @@ -535,6 +595,7 @@ public class MessagingController { latch.countDown(); } }); + try { latch.await(); } catch (Exception e) { @@ -543,7 +604,8 @@ public class MessagingController { } /** - * Start foreground synchronization of the specified folder. This is generally only called by synchronizeMailbox. + * Start foreground synchronization of the specified folder. This is generally only called + * by synchronizeMailbox. *

* TODO Break this method up into smaller chunks. */ @@ -551,6 +613,7 @@ public class MessagingController { void synchronizeMailboxSynchronous(Account account, long folderId, MessagingListener listener, NotificationState notificationState) { refreshFolderListIfStale(account); + Backend backend = getBackend(account); syncFolder(account, folderId, listener, backend, notificationState); } @@ -558,6 +621,7 @@ public class MessagingController { private void refreshFolderListIfStale(Account account) { long lastFolderListRefresh = account.getLastFolderListRefreshTime(); long now = System.currentTimeMillis(); + if (lastFolderListRefresh > now || lastFolderListRefresh + FOLDER_LIST_STALENESS_THRESHOLD <= now) { Timber.d("Last folder list refresh @ %tc. Refreshing now…", lastFolderListRefresh); refreshFolderListSynchronous(account); @@ -573,6 +637,7 @@ public class MessagingController { handleAuthenticationFailure(account, true); return; } + Exception commandException = null; try { processPendingCommandsSynchronous(account); @@ -580,6 +645,7 @@ public class MessagingController { Timber.e(e, "Failure processing command, but allow message sync attempt"); commandException = e; } + LocalFolder localFolder; try { LocalStore localStore = localStoreProvider.getInstance(account); @@ -589,18 +655,23 @@ public class MessagingController { Timber.e(e, "syncFolder: Couldn't load local folder %d", folderId); return; } + // We can't sync local folders if (localFolder.isLocalOnly()) { return; } + MessageStore messageStore = messageStoreManager.getMessageStore(account); Long lastChecked = messageStore.getFolder(folderId, FolderDetailsAccessor::getLastChecked); boolean suppressNotifications = lastChecked == null; + String folderServerId = localFolder.getServerId(); SyncConfig syncConfig = createSyncConfig(account); ControllerSyncListener syncListener = new ControllerSyncListener(account, listener, suppressNotifications, notificationState); + backend.sync(folderServerId, syncConfig, syncListener); + if (commandException != null && !syncListener.syncFailed) { String rootMessage = getRootCauseMessage(commandException); Timber.e("Root cause failure in %s:%s was '%s'", account.getDescription(), folderServerId, rootMessage); @@ -658,6 +729,7 @@ public class MessagingController { processPendingCommandsSynchronous(account); } catch (MessagingException me) { Timber.e(me, "processPendingCommands"); + /* * Ignore any exceptions from the commands. Commands will be processed * on the next round. @@ -670,12 +742,14 @@ public class MessagingController { public void processPendingCommandsSynchronous(Account account) throws MessagingException { LocalStore localStore = localStoreProvider.getInstance(account); List commands = localStore.getPendingCommands(); + PendingCommand processingCommand = null; try { for (PendingCommand command : commands) { processingCommand = command; String commandName = command.getCommandName(); Timber.d("Processing pending command '%s'", commandName); + /* * We specifically do not catch any exceptions here. If a command fails it is * most likely due to a server or IO error and it must be retried before any @@ -683,7 +757,9 @@ public class MessagingController { */ try { command.execute(this, account); + localStore.removePendingCommand(command); + Timber.d("Done processing pending command '%s'", commandName); } catch (MessagingException me) { if (me.isPermanentFailure()) { @@ -695,10 +771,12 @@ public class MessagingController { } catch (Exception e) { Timber.e(e, "Unexpected exception with command '%s', removing command from queue", commandName); localStore.removePendingCommand(processingCommand); + if (K9.DEVELOPER_MODE) { throw new AssertionError("Unexpected exception while processing pending command", e); } } + // TODO: When removing a pending command due to an error the local changes should be reverted. Pending // commands that depend on this command should be canceled and local changes be reverted. In most cases // the user should be notified about the failure as well. @@ -710,11 +788,11 @@ public class MessagingController { } } - /** - * Process a pending append message command. This command uploads a local message to the server, first checking to - * be sure that the server message is not newer than the local message. Once the local message is successfully - * processed it is deleted so that the server message will be synchronized down without an additional copy being + * Process a pending append message command. This command uploads a local message to the + * server, first checking to be sure that the server message is not newer than + * the local message. Once the local message is successfully processed it is deleted so + * that the server message will be synchronized down without an additional copy being * created. */ void processPendingAppend(PendingAppend command, Account account) throws MessagingException { @@ -722,36 +800,46 @@ public class MessagingController { long folderId = command.folderId; LocalFolder localFolder = localStore.getFolder(folderId); localFolder.open(); + String folderServerId = localFolder.getServerId(); String uid = command.uid; + LocalMessage localMessage = localFolder.getMessage(uid); if (localMessage == null) { return; } + if (!localMessage.getUid().startsWith(K9.LOCAL_UID_PREFIX)) { //FIXME: This should never happen. Throw in debug builds. return; } + Backend backend = getBackend(account); + if (localMessage.isSet(Flag.X_REMOTE_COPY_STARTED)) { Timber.w("Local message with uid %s has flag %s already set, checking for remote message with " + "same message id", localMessage.getUid(), X_REMOTE_COPY_STARTED); + String messageServerId = backend.findByMessageId(folderServerId, localMessage.getMessageId()); if (messageServerId != null) { Timber.w("Local message has flag %s already set, and there is a remote message with uid %s, " + "assuming message was already copied and aborting this copy", X_REMOTE_COPY_STARTED, messageServerId); + String oldUid = localMessage.getUid(); localMessage.setUid(messageServerId); localFolder.changeUid(localMessage); + for (MessagingListener l : getListeners()) { l.messageUidChanged(account, folderId, oldUid, localMessage.getUid()); } + return; } else { Timber.w("No remote message with message-id found, proceeding with append"); } } + /* * If the message does not exist remotely we just upload it and then * update our local copy with the new uid. @@ -761,7 +849,9 @@ public class MessagingController { localFolder.fetch(Collections.singletonList(localMessage), fp, null); String oldUid = localMessage.getUid(); localMessage.setFlag(Flag.X_REMOTE_COPY_STARTED, true); + String messageServerId = backend.uploadMessage(folderServerId, localMessage); + if (messageServerId == null) { // We didn't get the server UID of the uploaded message. Remove the local message now. The uploaded // version will be downloaded during the next sync. @@ -769,6 +859,7 @@ public class MessagingController { } else { localMessage.setUid(messageServerId); localFolder.changeUid(localMessage); + for (MessagingListener l : getListeners()) { l.messageUidChanged(account, folderId, oldUid, localMessage.getUid()); } @@ -802,8 +893,10 @@ public class MessagingController { long srcFolder = command.srcFolderId; long destFolder = command.destFolderId; MoveOrCopyFlavor operation = command.isCopy ? MoveOrCopyFlavor.COPY : MoveOrCopyFlavor.MOVE; + Map newUidMap = command.newUidMap; List uids = newUidMap != null ? new ArrayList<>(newUidMap.keySet()) : command.uids; + processPendingMoveOrCopy(account, srcFolder, destFolder, uids, operation, newUidMap); } @@ -812,6 +905,7 @@ public class MessagingController { long destFolder = command.destFolderId; Map newUidMap = command.newUidMap; List uids = new ArrayList<>(newUidMap.keySet()); + processPendingMoveOrCopy(account, srcFolder, destFolder, uids, MoveOrCopyFlavor.MOVE_AND_MARK_AS_READ, newUidMap); } @@ -820,14 +914,19 @@ public class MessagingController { void processPendingMoveOrCopy(Account account, long srcFolderId, long destFolderId, List uids, MoveOrCopyFlavor operation, Map newUidMap) throws MessagingException { checkNotNull(newUidMap); + LocalStore localStore = localStoreProvider.getInstance(account); + LocalFolder localSourceFolder = localStore.getFolder(srcFolderId); localSourceFolder.open(); String srcFolderServerId = localSourceFolder.getServerId(); + LocalFolder localDestFolder = localStore.getFolder(destFolderId); localDestFolder.open(); String destFolderServerId = localDestFolder.getServerId(); + Backend backend = getBackend(account); + Map remoteUidMap; switch (operation) { case COPY: @@ -842,27 +941,32 @@ public class MessagingController { default: throw new RuntimeException("Unsupported messaging operation"); } + if (operation != MoveOrCopyFlavor.COPY) { if (backend.getSupportsExpunge() && account.getExpungePolicy() == Expunge.EXPUNGE_IMMEDIATELY) { - Timber.i("processingPendingMoveOrCopy expunging folder %s:%s", account.getDescription(), - srcFolderServerId); + Timber.i("processingPendingMoveOrCopy expunging folder %s:%s", account.getDescription(), srcFolderServerId); backend.expungeMessages(srcFolderServerId, uids); } + destroyPlaceholderMessages(localSourceFolder, uids); } + // TODO: Change Backend interface to ensure we never receive null for remoteUidMap if (remoteUidMap == null) { remoteUidMap = Collections.emptyMap(); } + // Update local messages (that currently have local UIDs) with new server IDs for (String uid : uids) { String localUid = newUidMap.get(uid); String newUid = remoteUidMap.get(uid); + LocalMessage localMessage = localDestFolder.getMessage(localUid); if (localMessage == null) { // Local message no longer exists continue; } + if (newUid != null) { // Update local message with new server ID localMessage.setUid(newUid); @@ -883,11 +987,13 @@ public class MessagingController { if (placeholderMessage == null) { continue; } + if (placeholderMessage.isSet(Flag.DELETED)) { placeholderMessage.destroy(); } else { Timber.w("Expected local message %s in folder %s to be a placeholder, but DELETE flag wasn't set", uid, localFolder.getServerId()); + if (BuildConfig.DEBUG) { throw new AssertionError("Placeholder message must have the DELETED flag set"); } @@ -923,12 +1029,15 @@ public class MessagingController { void processPendingDelete(PendingDelete command, Account account) throws MessagingException { long folderId = command.folderId; List uids = command.uids; + Backend backend = getBackend(account); String folderServerId = getFolderServerId(account, folderId); backend.deleteMessages(folderServerId, uids); + if (backend.getSupportsExpunge() && account.getExpungePolicy() == Expunge.EXPUNGE_IMMEDIATELY) { backend.expungeMessages(folderServerId, uids); } + LocalStore localStore = localStoreProvider.getInstance(account); LocalFolder localFolder = localStore.getFolder(folderId); localFolder.open(); @@ -950,9 +1059,12 @@ public class MessagingController { long folderId = command.folderId; LocalStore localStore = localStoreProvider.getInstance(account); LocalFolder localFolder = localStore.getFolder(folderId); + localFolder.open(); String folderServerId = localFolder.getServerId(); + Timber.i("Marking all messages in %s:%s as read", account, folderServerId); + // TODO: Make this one database UPDATE operation List messages = localFolder.getMessages(null, false); for (Message message : messages) { @@ -960,9 +1072,11 @@ public class MessagingController { message.setFlag(Flag.SEEN, true); } } + for (MessagingListener l : getListeners()) { l.folderStatusChanged(account, folderId); } + Backend backend = getBackend(account); if (backend.getSupportsFlags()) { backend.markAllAsRead(folderServerId); @@ -977,7 +1091,9 @@ public class MessagingController { public void setFlag(final Account account, final List messageIds, final Flag flag, final boolean newState) { + setFlagInCache(account, messageIds, flag, newState); + threadPool.execute(new Runnable() { @Override public void run() { @@ -988,7 +1104,9 @@ public class MessagingController { public void setFlagForThreads(final Account account, final List threadRootIds, final Flag flag, final boolean newState) { + setFlagForThreadsInCache(account, threadRootIds, flag, newState); + threadPool.execute(new Runnable() { @Override public void run() { @@ -999,6 +1117,7 @@ public class MessagingController { private void setFlagSynchronous(final Account account, final List ids, final Flag flag, final boolean newState, final boolean threadedList) { + LocalStore localStore; try { localStore = localStoreProvider.getInstance(account); @@ -1006,6 +1125,7 @@ public class MessagingController { Timber.e(e, "Couldn't get LocalStore instance"); return; } + // Update affected messages in the database. This should be as fast as possible so the UI // can be updated with the new state. try { @@ -1019,6 +1139,7 @@ public class MessagingController { } catch (MessagingException e) { Timber.e(e, "Couldn't set flags in local database"); } + // Read folder ID and UID of messages from the database Map> folderMap; try { @@ -1027,15 +1148,19 @@ public class MessagingController { Timber.e(e, "Couldn't get folder name and UID of messages"); return; } + boolean accountSupportsFlags = supportsFlags(account); + // Loop over all folders for (Entry> entry : folderMap.entrySet()) { long folderId = entry.getKey(); List uids = entry.getValue(); + // Notify listeners of changed folder status for (MessagingListener l : getListeners()) { l.folderStatusChanged(account, folderId); } + if (accountSupportsFlags) { LocalFolder localFolder = localStore.getFolder(folderId); try { @@ -1065,11 +1190,14 @@ public class MessagingController { LocalStore localStore = localStoreProvider.getInstance(account); LocalFolder localFolder = localStore.getFolder(folderId); localFolder.open(); + // Update the messages in the local store localFolder.setFlags(messages, Collections.singleton(flag), newState); + for (MessagingListener l : getListeners()) { l.folderStatusChanged(account, folderId); } + // Handle the remote side if (supportsFlags(account) && !localFolder.isLocalOnly()) { List uids = getUidsFromMessages(messages); @@ -1089,6 +1217,7 @@ public class MessagingController { LocalStore localStore = localStoreProvider.getInstance(account); LocalFolder localFolder = localStore.getFolder(folderId); localFolder.open(); + LocalMessage message = localFolder.getMessage(uid); if (message != null) { setFlag(account, folderId, Collections.singletonList(message), flag, newState); @@ -1128,7 +1257,9 @@ public class MessagingController { LocalFolder localFolder = localStore.getFolder(folderId); localFolder.open(); String folderServerId = localFolder.getServerId(); + LocalMessage message = localFolder.getMessage(uid); + if (uid.startsWith(K9.LOCAL_UID_PREFIX)) { Timber.w("Message has local UID so cannot download fully."); // ASH move toast @@ -1141,17 +1272,21 @@ public class MessagingController { message.setFlag(Flag.X_DOWNLOADED_PARTIAL, false); } else { Backend backend = getBackend(account); + if (loadPartialFromSearch) { SyncConfig syncConfig = createSyncConfig(account); backend.downloadMessage(syncConfig, folderServerId, uid); } else { backend.downloadCompleteMessage(folderServerId, uid); } + message = localFolder.getMessage(uid); + if (!loadPartialFromSearch) { message.setFlag(Flag.X_DOWNLOADED_FULL, true); } } + // now that we have the full message, refresh the headers for (MessagingListener l : getListeners(listener)) { l.loadMessageRemoteFinished(account, folderId, uid); @@ -1169,16 +1304,20 @@ public class MessagingController { LocalStore localStore = localStoreProvider.getInstance(account); LocalFolder localFolder = localStore.getFolder(folderId); localFolder.open(); + LocalMessage message = localFolder.getMessage(uid); if (message == null || message.getDatabaseId() == 0) { String folderName = localFolder.getName(); throw new IllegalArgumentException("Message not found: folder=" + folderName + ", uid=" + uid); } + FetchProfile fp = new FetchProfile(); fp.add(FetchProfile.Item.BODY); localFolder.fetch(Collections.singletonList(message), fp, null); + notificationController.removeNewMailNotification(account, message.makeMessageReference()); markMessageAsReadOnView(account, message); + return message; } @@ -1186,35 +1325,43 @@ public class MessagingController { LocalStore localStore = localStoreProvider.getInstance(account); LocalFolder localFolder = localStore.getFolder(folderId); localFolder.open(); + LocalMessage message = localFolder.getMessage(uid); if (message == null || message.getDatabaseId() == 0) { String folderName = localFolder.getName(); throw new IllegalArgumentException("Message not found: folder=" + folderName + ", uid=" + uid); } + FetchProfile fp = new FetchProfile(); fp.add(FetchProfile.Item.ENVELOPE); localFolder.fetch(Collections.singletonList(message), fp, null); + return message; } private void markMessageAsReadOnView(Account account, LocalMessage message) throws MessagingException { + if (account.isMarkMessageAsReadOnView() && !message.isSet(Flag.SEEN)) { List messageIds = Collections.singletonList(message.getDatabaseId()); setFlag(account, messageIds, Flag.SEEN, true); + message.setFlagInternal(Flag.SEEN, true); } } public void loadAttachment(final Account account, final LocalMessage message, final Part part, final MessagingListener listener) { + put("loadAttachment", listener, new Runnable() { @Override public void run() { try { String folderServerId = message.getFolder().getServerId(); + LocalStore localStore = localStoreProvider.getInstance(account); LocalFolder localFolder = localStore.getFolder(folderServerId); + ProgressBodyFactory bodyFactory = new ProgressBodyFactory(new ProgressListener() { @Override public void updateProgress(int progress) { @@ -1223,14 +1370,18 @@ public class MessagingController { } } }); + Backend backend = getBackend(account); backend.fetchPart(folderServerId, message.getUid(), part, bodyFactory); + localFolder.addPartToMessage(message, part); + for (MessagingListener l : getListeners(listener)) { l.loadAttachmentFinished(account, message, part); } } catch (MessagingException me) { Timber.v(me, "Exception loading attachment"); + for (MessagingListener l : getListeners(listener)) { l.loadAttachmentFailed(account, message, part, me.getMessage()); } @@ -1250,14 +1401,18 @@ public class MessagingController { Timber.e("Error sending message. No Outbox folder configured."); return; } + message.setFlag(Flag.SEEN, true); + MessageStore messageStore = messageStoreManager.getMessageStore(account); SaveMessageData messageData = saveMessageDataCreator.createSaveMessageData( message, MessageDownloadState.FULL, plaintextSubject); long messageId = messageStore.saveLocalMessage(outboxFolderId, messageData, null); + LocalStore localStore = localStoreProvider.getInstance(account); OutboxStateRepository outboxStateRepository = localStore.getOutboxStateRepository(); outboxStateRepository.initializeOutboxState(messageId); + sendPendingMessages(account, listener); } catch (Exception e) { Timber.e(e, "Error sending message"); @@ -1269,7 +1424,6 @@ public class MessagingController { backend.sendMessage(message); } - /** * Attempt to send any messages that are sitting in the Outbox. */ @@ -1279,7 +1433,9 @@ public class MessagingController { @Override public void run() { if (messagesPendingSend(account)) { + showSendingNotificationIfNecessary(account); + try { sendPendingMessagesSynchronous(account); } finally { @@ -1308,6 +1464,7 @@ public class MessagingController { Timber.w("Could not get Outbox folder ID from Account"); return false; } + MessageStore messageStore = messageStoreManager.getMessageStore(account); return messageStore.getMessageCount(outboxFolderId) > 0; } @@ -1325,6 +1482,7 @@ public class MessagingController { handleAuthenticationFailure(account, false); return; } + LocalStore localStore = localStoreProvider.getInstance(account); OutboxStateRepository outboxStateRepository = localStore.getOutboxStateRepository(); LocalFolder localFolder = localStore.getFolder(account.getOutboxFolderId()); @@ -1332,8 +1490,11 @@ public class MessagingController { Timber.v("Outbox does not exist"); return; } + localFolder.open(); + long outboxFolderId = localFolder.getDatabaseId(); + List localMessages = localFolder.getMessages(null); int progress = 0; int todo = localMessages.size(); @@ -1347,8 +1508,11 @@ public class MessagingController { FetchProfile fp = new FetchProfile(); fp.add(FetchProfile.Item.ENVELOPE); fp.add(FetchProfile.Item.BODY); + Timber.i("Scanning Outbox folder for messages to send"); + Backend backend = getBackend(account); + for (LocalMessage message : localMessages) { if (message.isSet(Flag.DELETED)) { //FIXME: When uploading a message to the remote Sent folder the move code creates a placeholder @@ -1360,14 +1524,17 @@ public class MessagingController { try { long messageId = message.getDatabaseId(); OutboxState outboxState = outboxStateRepository.getOutboxState(messageId); + if (outboxState.getSendState() != SendState.READY) { Timber.v("Skipping sending message " + message.getUid()); notificationController.showSendFailedNotification(account, new MessagingException(message.getSubject())); continue; } + Timber.i("Send count for message %s is %d", message.getUid(), outboxState.getNumberOfSendAttempts()); + localFolder.fetch(Collections.singletonList(message), fp, null); try { if (message.getHeader(K9.IDENTITY_HEADER).length > 0 || message.isSet(Flag.DRAFT)) { @@ -1375,10 +1542,13 @@ public class MessagingController { "This message appears to be a draft, so K-9 will not send it"); continue; } + outboxStateRepository.incrementSendAttempts(messageId); message.setFlag(Flag.X_SEND_IN_PROGRESS, true); + Timber.i("Sending message with UID %s", message.getUid()); backend.sendMessage(message); + message.setFlag(Flag.X_SEND_IN_PROGRESS, false); message.setFlag(Flag.SEEN, true); progress++; @@ -1386,32 +1556,38 @@ public class MessagingController { l.synchronizeMailboxProgress(account, outboxFolderId, progress, todo); } moveOrDeleteSentMessage(account, localStore, message); + outboxStateRepository.removeOutboxState(messageId); } catch (AuthenticationFailedException e) { outboxStateRepository.decrementSendAttempts(messageId); lastFailure = e; wasPermanentFailure = false; + handleAuthenticationFailure(account, false); handleSendFailure(account, localFolder, message, e); } catch (CertificateValidationException e) { outboxStateRepository.decrementSendAttempts(messageId); lastFailure = e; wasPermanentFailure = false; + notifyUserIfCertificateProblem(account, e, false); handleSendFailure(account, localFolder, message, e); } catch (MessagingException e) { lastFailure = e; wasPermanentFailure = e.isPermanentFailure(); + if (wasPermanentFailure) { String errorMessage = e.getMessage(); outboxStateRepository.setSendAttemptError(messageId, errorMessage); } else if (outboxState.getNumberOfSendAttempts() + 1 >= MAX_SEND_ATTEMPTS) { outboxStateRepository.setSendAttemptsExceeded(messageId); } + handleSendFailure(account, localFolder, message, e); } catch (Exception e) { lastFailure = e; wasPermanentFailure = true; + handleSendFailure(account, localFolder, message, e); } } catch (Exception e) { @@ -1421,6 +1597,7 @@ public class MessagingController { notifySynchronizeMailboxFailed(account, localFolder, e); } } + if (lastFailure != null) { if (wasPermanentFailure) { notificationController.showSendFailedNotification(account, lastFailure); @@ -1448,9 +1625,12 @@ public class MessagingController { sentFolder.open(); String sentFolderServerId = sentFolder.getServerId(); Timber.i("Moving sent message to folder '%s' (%d)", sentFolderServerId, sentFolderId); + MessageStore messageStore = messageStoreManager.getMessageStore(account); long destinationMessageId = messageStore.moveMessage(message.getDatabaseId(), sentFolderId); + Timber.i("Moved sent message to folder '%s' (%d)", sentFolderServerId, sentFolderId); + if (!sentFolder.isLocalOnly()) { String destinationUid = messageStore.getMessageServerId(destinationMessageId); PendingCommand command = PendingAppend.create(sentFolderId, destinationUid); @@ -1462,8 +1642,10 @@ public class MessagingController { private void handleSendFailure(Account account, LocalFolder localFolder, Message message, Exception exception) throws MessagingException { + Timber.e(exception, "Failed to send message"); message.setFlag(Flag.X_SEND_FAILED, true); + notifySynchronizeMailboxFailed(account, localFolder, exception); } @@ -1539,6 +1721,7 @@ public class MessagingController { List messageReferences, long destFolderId) { actOnMessageGroup(srcAccount, srcFolderId, messageReferences, (account, messageFolder, messages) -> { suppressMessages(account, messages); + putBackground("moveMessages", null, () -> moveOrCopyMessageSynchronous(account, srcFolderId, messages, destFolderId, MoveOrCopyFlavor.MOVE) ); @@ -1549,6 +1732,7 @@ public class MessagingController { List messageReferences, long destFolderId) { actOnMessageGroup(srcAccount, srcFolderId, messageReferences, (account, messageFolder, messages) -> { suppressMessages(account, messages); + putBackground("moveMessagesInThread", null, () -> { try { List messagesInThreads = collectMessagesInThreads(account, messages); @@ -1595,9 +1779,11 @@ public class MessagingController { private void moveOrCopyMessageSynchronous(Account account, long srcFolderId, List inMessages, long destFolderId, MoveOrCopyFlavor operation) { + if (operation == MoveOrCopyFlavor.MOVE_AND_MARK_AS_READ) { throw new UnsupportedOperationException("MOVE_AND_MARK_AS_READ unsupported"); } + try { LocalStore localStore = localStoreProvider.getInstance(account); if (operation == MoveOrCopyFlavor.MOVE && !isMoveCapable(account)) { @@ -1606,10 +1792,13 @@ public class MessagingController { if (operation == MoveOrCopyFlavor.COPY && !isCopyCapable(account)) { return; } + LocalFolder localSrcFolder = localStore.getFolder(srcFolderId); localSrcFolder.open(); + LocalFolder localDestFolder = localStore.getFolder(destFolderId); localDestFolder.open(); + boolean unreadCountAffected = false; List uids = new LinkedList<>(); for (Message message : inMessages) { @@ -1617,15 +1806,19 @@ public class MessagingController { if (!uid.startsWith(K9.LOCAL_UID_PREFIX)) { uids.add(uid); } + if (!unreadCountAffected && !message.isSet(Flag.SEEN)) { unreadCountAffected = true; } } + List messages = localSrcFolder.getMessagesByUids(uids); if (messages.size() > 0) { Timber.i("moveOrCopyMessageSynchronous: source folder = %s, %d messages, destination folder = %s, " + "operation = %s", srcFolderId, messages.size(), destFolderId, operation.name()); + MessageStore messageStore = messageStoreManager.getMessageStore(account); + List messageIds = new ArrayList<>(); Map messageIdToUidMapping = new HashMap<>(); for (LocalMessage message : messages) { @@ -1633,9 +1826,11 @@ public class MessagingController { messageIds.add(messageId); messageIdToUidMapping.put(messageId, message.getUid()); } + Map resultIdMapping; if (operation == MoveOrCopyFlavor.COPY) { resultIdMapping = messageStore.copyMessages(messageIds, destFolderId); + if (unreadCountAffected) { // If this copy operation changes the unread count in the destination // folder, notify the listeners. @@ -1645,7 +1840,9 @@ public class MessagingController { } } else { resultIdMapping = messageStore.moveMessages(messageIds, destFolderId); + unsuppressMessages(account, messages); + if (unreadCountAffected) { // If this move operation changes the unread count, notify the listeners // that the unread count changed in both the source and destination folder. @@ -1655,25 +1852,30 @@ public class MessagingController { } } } + Map destinationMapping = messageStore.getMessageServerIds(resultIdMapping.values()); + Map uidMap = new HashMap<>(); for (Entry entry : resultIdMapping.entrySet()) { long sourceMessageId = entry.getKey(); long destinationMessageId = entry.getValue(); + String sourceUid = messageIdToUidMapping.get(sourceMessageId); String destinationUid = destinationMapping.get(destinationMessageId); uidMap.put(sourceUid, destinationUid); } + queueMoveOrCopy(account, localSrcFolder.getDatabaseId(), localDestFolder.getDatabaseId(), operation, uidMap); } + processPendingCommands(account); } catch (MessagingException me) { throw new RuntimeException("Error moving message", me); } } - public void moveToDraftsFolder(Account account, long folderId, List messages) { + public void moveToDraftsFolder(Account account, long folderId, List messages){ putBackground("moveToDrafts", null, () -> moveToDraftsFolderInBackground(account, folderId, messages)); } @@ -1682,10 +1884,12 @@ public class MessagingController { try { Message message = loadMessage(account, folderId, messageReference.getUid()); Long draftMessageId = saveDraft(account, message, null, message.getSubject()); + boolean draftSavedSuccessfully = draftMessageId != null; if (draftSavedSuccessfully) { message.destroy(); } + for (MessagingListener listener : getListeners()) { listener.folderStatusChanged(account, folderId); } @@ -1709,12 +1913,13 @@ public class MessagingController { Timber.w("No Drafts folder configured. Can't delete draft."); return; } + LocalStore localStore = localStoreProvider.getInstance(account); LocalFolder localFolder = localStore.getFolder(folderId); localFolder.open(); String uid = localFolder.getMessageUidById(id); if (uid != null) { - MessageReference messageReference = new MessageReference(account.getUuid(), folderId, uid, null); + MessageReference messageReference = new MessageReference(account.getUuid(), folderId, uid); deleteMessage(messageReference); } } catch (MessagingException me) { @@ -1742,14 +1947,19 @@ public class MessagingController { private List collectMessagesInThreads(Account account, List messages) throws MessagingException { + LocalStore localStore = localStoreProvider.getInstance(account); + List messagesInThreads = new ArrayList<>(); for (LocalMessage localMessage : messages) { long rootId = localMessage.getRootId(); long threadId = (rootId == -1) ? localMessage.getThreadId() : rootId; + List messagesInThread = localStore.getMessagesInThread(threadId); + messagesInThreads.addAll(messagesInThread); } + return messagesInThreads; } @@ -1771,10 +1981,13 @@ public class MessagingController { 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() { @@ -1789,6 +2002,7 @@ public class MessagingController { }); } }); + } private void deleteMessagesSynchronous(Account account, long folderId, List messages) { @@ -1805,16 +2019,20 @@ public class MessagingController { syncedMessageUids.add(uid); } } + Backend backend = getBackend(account); + LocalStore localStore = localStoreProvider.getInstance(account); LocalFolder localFolder = localStore.getFolder(folderId); localFolder.open(); + Map uidMap = null; Long trashFolderId = account.getTrashFolderId(); LocalFolder localTrashFolder = null; if (!account.hasTrashFolder() || folderId == trashFolderId || (backend.getSupportsTrashFolder() && !backend.isDeleteMoveToTrash())) { Timber.d("Not moving deleted messages to local Trash folder. Removing local copies."); + if (!localOnlyMessages.isEmpty()) { localFolder.destroyMessages(localOnlyMessages); } @@ -1824,7 +2042,9 @@ public class MessagingController { } else { Timber.d("Deleting messages in normal folder, moving"); localTrashFolder = localStore.getFolder(trashFolderId); + MessageStore messageStore = messageStoreManager.getMessageStore(account); + List messageIds = new ArrayList<>(); Map messageIdToUidMapping = new HashMap<>(); for (LocalMessage message : messages) { @@ -1832,28 +2052,35 @@ public class MessagingController { messageIds.add(messageId); messageIdToUidMapping.put(messageId, message.getUid()); } + Map moveMessageIdMapping = messageStore.moveMessages(messageIds, trashFolderId); + Map destinationMapping = messageStore.getMessageServerIds(moveMessageIdMapping.values()); uidMap = new HashMap<>(); for (Entry entry : moveMessageIdMapping.entrySet()) { long sourceMessageId = entry.getKey(); long destinationMessageId = entry.getValue(); + String sourceUid = messageIdToUidMapping.get(sourceMessageId); String destinationUid = destinationMapping.get(destinationMessageId); uidMap.put(sourceUid, destinationUid); } + if (account.isMarkMessageAsReadOnDelete()) { Collection destinationMessageIds = moveMessageIdMapping.values(); messageStore.setFlag(destinationMessageIds, Flag.SEEN, true); } } + for (MessagingListener l : getListeners()) { l.folderStatusChanged(account, folderId); if (localTrashFolder != null) { l.folderStatusChanged(account, trashFolderId); } } + Timber.d("Delete policy for account %s is %s", account.getDescription(), account.getDeletePolicy()); + Long outboxFolderId = account.getOutboxFolderId(); if (outboxFolderId != null && folderId == outboxFolderId && supportsUpload(account)) { for (String destinationUid : uidMap.values()) { @@ -1885,6 +2112,7 @@ public class MessagingController { Timber.d("Delete policy %s prevents delete from server", account.getDeletePolicy()); } } + unsuppressMessages(account, messages); } catch (MessagingException me) { throw new RuntimeException("Error deleting message from local store.", me); @@ -1903,18 +2131,23 @@ public class MessagingController { if (!account.hasTrashFolder()) { return; } + long trashFolderId = account.getTrashFolderId(); LocalStore localStore = localStoreProvider.getInstance(account); LocalFolder folder = localStore.getFolder(trashFolderId); folder.open(); String trashFolderServerId = folder.getServerId(); + Backend backend = getBackend(account); backend.deleteAllMessages(trashFolderServerId); + if (account.getExpungePolicy() == Expunge.EXPUNGE_IMMEDIATELY && backend.getSupportsExpunge()) { backend.expunge(trashFolderServerId); } + // Remove all messages marked as deleted folder.destroyDeletedMessages(); + compact(account, null); } @@ -1928,9 +2161,11 @@ public class MessagingController { Timber.w("No Trash folder configured. Can't empty trash."); return; } + LocalStore localStore = localStoreProvider.getInstance(account); LocalFolder localFolder = localStore.getFolder(trashFolderId); localFolder.open(); + boolean isTrashLocalOnly = isTrashLocalOnly(account); if (isTrashLocalOnly) { localFolder.clearAllMessages(); @@ -1938,9 +2173,11 @@ public class MessagingController { localFolder.destroyLocalOnlyMessages(); localFolder.setFlags(Collections.singleton(Flag.DELETED), true); } + for (MessagingListener l : getListeners()) { l.folderStatusChanged(account, trashFolderId); } + if (!isTrashLocalOnly) { PendingCommand command = PendingEmptyTrash.create(); queuePendingCommand(account, command); @@ -1970,14 +2207,17 @@ public class MessagingController { } } + /** * Find out whether the account type only supports a local Trash folder. *

*

Note: Currently this is only the case for POP3 accounts.

* - * @param account The account to check. - * @return {@code true} if the account only has a local Trash folder that is not synchronized with a folder on the - * server. {@code false} otherwise. + * @param account + * The account to check. + * + * @return {@code true} if the account only has a local Trash folder that is not synchronized + * with a folder on the server. {@code false} otherwise. */ private boolean isTrashLocalOnly(Account account) { Backend backend = getBackend(account); @@ -1998,13 +2238,16 @@ public class MessagingController { syncError.setValue(true); } }); + Timber.v("performPeriodicMailSync(%s) about to await latch release", account.getDescription()); + try { latch.await(); Timber.v("performPeriodicMailSync(%s) got latch release", account.getDescription()); } catch (Exception e) { Timber.e(e, "Interrupted while awaiting latch release"); } + boolean success = !syncError.getValue(); if (success) { long now = System.currentTimeMillis(); @@ -2012,32 +2255,40 @@ public class MessagingController { account.setLastSyncTime(now); preferences.saveAccount(account); } + return success; } /** - * Checks mail for one or multiple accounts. If account is null all accounts are checked. + * Checks mail for one or multiple accounts. If account is null all accounts + * are checked. */ public void checkMail(final Account account, final boolean ignoreLastCheckedTime, final boolean useManualWakeLock, final MessagingListener listener) { - TracingWakeLock twakeLock = null; + + final WakeLock wakeLock; if (useManualWakeLock) { - TracingPowerManager pm = TracingPowerManager.getPowerManager(context); - twakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "K9 MessagingController.checkMail"); - twakeLock.setReferenceCounted(false); - twakeLock.acquire(K9.MANUAL_WAKE_LOCK_TIMEOUT); + PowerManager pm = DI.get(PowerManager.class); + + wakeLock = pm.newWakeLock("K9 MessagingController.checkMail"); + wakeLock.setReferenceCounted(false); + wakeLock.acquire(K9.MANUAL_WAKE_LOCK_TIMEOUT); + } else { + wakeLock = null; } - final TracingWakeLock wakeLock = twakeLock; + for (MessagingListener l : getListeners(listener)) { l.checkMailStarted(context, account); } putBackground("checkMail", listener, new Runnable() { @Override public void run() { + try { Timber.i("Starting mail check"); + Collection accounts; if (account != null) { accounts = new ArrayList<>(1); @@ -2045,22 +2296,27 @@ public class MessagingController { } else { accounts = preferences.getAccounts(); } + for (final Account account : accounts) { checkMailForAccount(context, account, ignoreLastCheckedTime, listener); } + } catch (Exception e) { Timber.e(e, "Unable to synchronize mail"); } putBackground("finalize sync", null, new Runnable() { @Override public void run() { + Timber.i("Finished mail sync"); + if (wakeLock != null) { wakeLock.release(); } for (MessagingListener l : getListeners(listener)) { l.checkMailFinished(context, account); } + } } ); @@ -2068,22 +2324,29 @@ public class MessagingController { }); } + private void checkMailForAccount(final Context context, final Account account, final boolean ignoreLastCheckedTime, final MessagingListener listener) { - Timber.i("Synchronizing account %s", account.getDescription()); + NotificationState notificationState = new NotificationState(); + sendPendingMessages(account, listener); + refreshFolderListIfStale(account); + try { Account.FolderMode aDisplayMode = account.getFolderDisplayMode(); Account.FolderMode aSyncMode = account.getFolderSyncMode(); + LocalStore localStore = localStoreProvider.getInstance(account); for (final LocalFolder folder : localStore.getPersonalNamespaces(false)) { folder.open(); + FolderClass fDisplayClass = folder.getDisplayClass(); FolderClass fSyncClass = folder.getSyncClass(); + if (LocalFolder.isModeMismatch(aDisplayMode, fDisplayClass)) { // Never sync a folder that isn't displayed /* @@ -2092,8 +2355,10 @@ public class MessagingController { " which is in display mode " + fDisplayClass + " while account is in display mode " + aDisplayMode); } */ + continue; } + if (LocalFolder.isModeMismatch(aSyncMode, fSyncClass)) { // Do not sync folders in the wrong class /* @@ -2102,6 +2367,7 @@ public class MessagingController { " which is in sync mode " + fSyncClass + " while account is in sync mode " + aSyncMode); } */ + continue; } synchronizeFolder(account, folder, ignoreLastCheckedTime, listener, notificationState); @@ -2123,6 +2389,8 @@ public class MessagingController { } ); } + + } private void synchronizeFolder(Account account, LocalFolder folder, boolean ignoreLastCheckedTime, @@ -2135,9 +2403,11 @@ public class MessagingController { private void synchronizeFolderInBackground(Account account, LocalFolder folder, boolean ignoreLastCheckedTime, MessagingListener listener, NotificationState notificationState) { Timber.v("Folder %s was last synced @ %tc", folder.getServerId(), folder.getLastChecked()); + if (!ignoreLastCheckedTime) { long lastCheckedTime = folder.getLastChecked(); long now = System.currentTimeMillis(); + if (lastCheckedTime > now) { // The time this folder was last checked lies in the future. We better ignore this and sync now. } else { @@ -2150,6 +2420,7 @@ public class MessagingController { } } } + try { showFetchingMailNotificationIfNecessary(account, folder); try { @@ -2197,7 +2468,6 @@ public class MessagingController { }); } - public void deleteAccount(Account account) { notificationController.clearNewMailNotifications(account); memorizingMessagingListener.removeAccount(account); @@ -2226,6 +2496,7 @@ public class MessagingController { public MessagingListener listener; public String description; boolean isForegroundPriority; + int sequence = sequencing.getAndIncrement(); @Override @@ -2270,18 +2541,22 @@ public class MessagingController { if (!(exception instanceof CertificateValidationException)) { return; } + CertificateValidationException cve = (CertificateValidationException) exception; if (!cve.needsUserAttention()) { return; } + notificationController.showCertificateErrorNotification(account, incoming); } private void actOnMessagesGroupedByAccountAndFolder(List messages, MessageActor actor) { Map>> accountMap = groupMessagesByAccountAndFolder(messages); + for (Map.Entry>> entry : accountMap.entrySet()) { String accountUuid = entry.getKey(); Account account = preferences.getAccount(accountUuid); + Map> folderMap = entry.getValue(); for (Map.Entry> folderEntry : folderMap.entrySet()) { long folderId = folderEntry.getKey(); @@ -2295,12 +2570,14 @@ public class MessagingController { private Map>> groupMessagesByAccountAndFolder( List messages) { Map>> accountMap = new HashMap<>(); + for (MessageReference message : messages) { if (message == null) { continue; } String accountUuid = message.getAccountUuid(); long folderId = message.getFolderId(); + Map> folderMap = accountMap.get(accountUuid); if (folderMap == null) { folderMap = new HashMap<>(); @@ -2311,6 +2588,7 @@ public class MessagingController { messageList = new LinkedList<>(); folderMap.put(folderId, messageList); } + messageList.add(message); } return accountMap; @@ -2325,6 +2603,7 @@ public class MessagingController { } catch (MessagingException e) { Timber.e(e, "Error loading account?!"); } + } private interface MessageActor { @@ -2335,11 +2614,11 @@ public class MessagingController { private final Account account; private final MessagingListener listener; private final LocalStore localStore; - private final int previousUnreadMessageCount; private final boolean suppressNotifications; private final NotificationState notificationState; boolean syncFailed = false; + ControllerSyncListener(Account account, MessagingListener listener, boolean suppressNotifications, NotificationState notificationState) { this.account = account; @@ -2347,7 +2626,6 @@ public class MessagingController { this.suppressNotifications = suppressNotifications; this.notificationState = notificationState; this.localStore = getLocalStoreOrThrow(account); - previousUnreadMessageCount = getUnreadMessageCount(account); } @Override @@ -2397,6 +2675,7 @@ public class MessagingController { @Override public void syncNewMessage(@NotNull String folderServerId, @NotNull String messageServerId, boolean isOldMessage) { + // Send a notification of this message LocalMessage message = loadMessage(folderServerId, messageServerId); LocalFolder localFolder = message.getFolder(); @@ -2405,9 +2684,10 @@ public class MessagingController { Timber.v("Creating notification for message %s:%s", localFolder.getName(), message.getUid()); // Notify with the localMessage so that we don't have to recalculate the content preview. boolean silent = notificationState.wasNotified(); - notificationController.addNewMailNotification(account, message, previousUnreadMessageCount, silent); + notificationController.addNewMailNotification(account, message, silent); notificationState.setWasNotified(true); } + if (!message.isSet(Flag.SEEN)) { for (MessagingListener messagingListener : getListeners(listener)) { messagingListener.synchronizeMailboxNewMessage(account, folderServerId, message); @@ -2420,9 +2700,10 @@ public class MessagingController { for (MessagingListener messagingListener : getListeners(listener)) { messagingListener.synchronizeMailboxRemovedMessage(account, folderServerId, messageServerId); } + String accountUuid = account.getUuid(); long folderId = getFolderId(account, folderServerId); - MessageReference messageReference = new MessageReference(accountUuid, folderId, messageServerId, null); + MessageReference messageReference = new MessageReference(accountUuid, folderId, messageServerId); notificationController.removeNewMailNotification(account, messageReference); } @@ -2438,6 +2719,7 @@ public class MessagingController { shouldBeNotifiedOf = true; } } + // we're only interested in messages that need removing if (!shouldBeNotifiedOf) { MessageReference messageReference = message.makeMessageReference(); @@ -2445,7 +2727,6 @@ public class MessagingController { } } - @Override public void syncFinished(@NotNull String folderServerId) { long folderId = getFolderId(account, folderServerId); @@ -2457,18 +2738,19 @@ public class MessagingController { @Override public void syncFailed(@NotNull String folderServerId, @NotNull String message, Exception exception) { syncFailed = true; + if (exception instanceof AuthenticationFailedException) { handleAuthenticationFailure(account, true); } else { notifyUserIfCertificateProblem(account, exception, true); } + long folderId = getFolderId(account, folderServerId); for (MessagingListener messagingListener : getListeners(listener)) { messagingListener.synchronizeMailboxFailed(account, folderId, message); } } - @Override public void folderStatusChanged(@NotNull String folderServerId) { long folderId = getFolderId(account, folderServerId); @@ -2491,4 +2773,4 @@ public class MessagingController { private enum MoveOrCopyFlavor { MOVE, COPY, MOVE_AND_MARK_AS_READ } -} \ No newline at end of file +} diff --git a/app/core/src/main/java/com/fsck/k9/controller/NotificationState.kt b/app/core/src/main/java/com/fsck/k9/controller/NotificationState.kt index f9878abfb020bc99b3b797d9852695a41c258cff..32306f5b25379b6df311dd7cdb8dd87967239b31 100644 --- a/app/core/src/main/java/com/fsck/k9/controller/NotificationState.kt +++ b/app/core/src/main/java/com/fsck/k9/controller/NotificationState.kt @@ -3,4 +3,4 @@ package com.fsck.k9.controller class NotificationState { @get:JvmName("wasNotified") var wasNotified: Boolean = false -} \ No newline at end of file +} diff --git a/app/core/src/main/java/com/fsck/k9/controller/ProgressBodyFactory.java b/app/core/src/main/java/com/fsck/k9/controller/ProgressBodyFactory.java index 606541f27fa7baebc58e501affacd6ffa649dc2e..4fe052cd54f4165019236bff992a29907355778a 100644 --- a/app/core/src/main/java/com/fsck/k9/controller/ProgressBodyFactory.java +++ b/app/core/src/main/java/com/fsck/k9/controller/ProgressBodyFactory.java @@ -21,10 +21,8 @@ class ProgressBodyFactory extends DefaultBodyFactory { @Override protected void copyData(InputStream inputStream, OutputStream outputStream) throws IOException { - final CountingOutputStream countingOutputStream = new CountingOutputStream(outputStream); - Timer timer = new Timer(); - try { + try (CountingOutputStream countingOutputStream = new CountingOutputStream(outputStream)) { timer.scheduleAtFixedRate(new TimerTask() { @Override public void run() { diff --git a/app/core/src/main/java/com/fsck/k9/controller/push/AccountPushController.kt b/app/core/src/main/java/com/fsck/k9/controller/push/AccountPushController.kt index 9688b95db8336277f963eda610f3f1a09411a361..c735c20bc6787ac251b3db3b37785739cc8f3d39 100644 --- a/app/core/src/main/java/com/fsck/k9/controller/push/AccountPushController.kt +++ b/app/core/src/main/java/com/fsck/k9/controller/push/AccountPushController.kt @@ -7,13 +7,12 @@ import com.fsck.k9.backend.BackendManager import com.fsck.k9.backend.api.BackendPusher import com.fsck.k9.backend.api.BackendPusherCallback import com.fsck.k9.controller.MessagingController -import com.fsck.k9.mailstore.FolderRepositoryManager +import com.fsck.k9.mailstore.FolderRepository import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.collect -import com.fsck.k9.mailstore.FolderRepository import kotlinx.coroutines.launch import timber.log.Timber @@ -101,4 +100,4 @@ internal class AccountPushController( account.folderPushMode = FolderMode.NONE preferences.saveAccount(account) } -} \ No newline at end of file +} diff --git a/app/core/src/main/java/com/fsck/k9/controller/push/AccountPushControllerFactory.kt b/app/core/src/main/java/com/fsck/k9/controller/push/AccountPushControllerFactory.kt index edadccbda4080b1aaff59675adcfb889878e7931..7091496d69ae22a5b744dcc7443cc79cf806bfbf 100644 --- a/app/core/src/main/java/com/fsck/k9/controller/push/AccountPushControllerFactory.kt +++ b/app/core/src/main/java/com/fsck/k9/controller/push/AccountPushControllerFactory.kt @@ -1,9 +1,9 @@ package com.fsck.k9.controller.push import com.fsck.k9.Account +import com.fsck.k9.Preferences import com.fsck.k9.backend.BackendManager import com.fsck.k9.controller.MessagingController -import com.fsck.k9.Preferences import com.fsck.k9.mailstore.FolderRepository internal class AccountPushControllerFactory( diff --git a/app/core/src/main/java/com/fsck/k9/helper/MessageHelper.java b/app/core/src/main/java/com/fsck/k9/helper/MessageHelper.java index 11abdaeb8229ecf9b38b0d871d6280136bf007a3..d87c02a6b743fcb2aee7c9c25df169933607b3fc 100644 --- a/app/core/src/main/java/com/fsck/k9/helper/MessageHelper.java +++ b/app/core/src/main/java/com/fsck/k9/helper/MessageHelper.java @@ -2,6 +2,8 @@ package com.fsck.k9.helper; import java.util.regex.Pattern; +import java.util.regex.Pattern; + import android.content.Context; import android.text.Spannable; import android.text.SpannableString; diff --git a/app/core/src/main/java/com/fsck/k9/job/KoinModule.kt b/app/core/src/main/java/com/fsck/k9/job/KoinModule.kt index 4bf3a7002f7e89663ed3ae854cd3a9ff82855ee0..4dd6f0e8fa3e15d5916db695765b0a70583fd8fa 100644 --- a/app/core/src/main/java/com/fsck/k9/job/KoinModule.kt +++ b/app/core/src/main/java/com/fsck/k9/job/KoinModule.kt @@ -1,7 +1,6 @@ package com.fsck.k9.job import androidx.work.WorkerFactory -import com.fsck.k9.Clock import org.koin.dsl.module val jobModule = module { @@ -9,5 +8,5 @@ val jobModule = module { single { K9WorkerFactory(get(), get()) } single { get().getWorkManager() } single { K9JobManager(get(), get(), get()) } - factory { MailSyncWorkerManager(get(), Clock.INSTANCE) } + factory { MailSyncWorkerManager(workManager = get(), clock = get()) } } diff --git a/app/core/src/main/java/com/fsck/k9/job/MailSyncWorker.kt b/app/core/src/main/java/com/fsck/k9/job/MailSyncWorker.kt index 249cc7b2baf41b6fb467b29986e8fa488bae3638..d0a7ef0f8c31859bc974f6631b253f5c456daafd 100644 --- a/app/core/src/main/java/com/fsck/k9/job/MailSyncWorker.kt +++ b/app/core/src/main/java/com/fsck/k9/job/MailSyncWorker.kt @@ -43,6 +43,11 @@ class MailSyncWorker( return Result.success() } + if (account.incomingServerSettings.isMissingCredentials) { + Timber.d("Password for this account is missing. Skipping mail sync.") + return Result.success() + } + val success = messagingController.performPeriodicMailSync(account) return if (success) Result.success() else Result.retry() diff --git a/app/core/src/main/java/com/fsck/k9/logging/KoinModule.kt b/app/core/src/main/java/com/fsck/k9/logging/KoinModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..4f0cf6048020b5116756408dac2be607dca44f12 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/logging/KoinModule.kt @@ -0,0 +1,8 @@ +package com.fsck.k9.logging + +import org.koin.dsl.module + +val loggingModule = module { + factory { RealProcessExecutor() } + factory { LogcatLogFileWriter(contentResolver = get(), processExecutor = get()) } +} diff --git a/app/core/src/main/java/com/fsck/k9/logging/LogFileWriter.kt b/app/core/src/main/java/com/fsck/k9/logging/LogFileWriter.kt new file mode 100644 index 0000000000000000000000000000000000000000..d81c9e16611ab7b3507d66392440b0b572e1c67a --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/logging/LogFileWriter.kt @@ -0,0 +1,38 @@ +package com.fsck.k9.logging + +import android.content.ContentResolver +import android.net.Uri +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.apache.commons.io.IOUtils +import timber.log.Timber + +interface LogFileWriter { + suspend fun writeLogTo(contentUri: Uri) +} + +class LogcatLogFileWriter( + private val contentResolver: ContentResolver, + private val processExecutor: ProcessExecutor, + private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO +) : LogFileWriter { + override suspend fun writeLogTo(contentUri: Uri) { + return withContext(coroutineDispatcher) { + writeLogBlocking(contentUri) + } + } + + private fun writeLogBlocking(contentUri: Uri) { + Timber.v("Writing logcat output to content URI: %s", contentUri) + + val outputStream = contentResolver.openOutputStream(contentUri) + ?: error("Error opening contentUri for writing") + + outputStream.use { + processExecutor.exec("logcat -d").use { inputStream -> + IOUtils.copy(inputStream, outputStream) + } + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/logging/ProcessExecutor.kt b/app/core/src/main/java/com/fsck/k9/logging/ProcessExecutor.kt new file mode 100644 index 0000000000000000000000000000000000000000..cfa2ff653fc425eacc0774815cfd76c9009dfb90 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/logging/ProcessExecutor.kt @@ -0,0 +1,14 @@ +package com.fsck.k9.logging + +import java.io.InputStream + +interface ProcessExecutor { + fun exec(command: String): InputStream +} + +class RealProcessExecutor : ProcessExecutor { + override fun exec(command: String): InputStream { + val process = Runtime.getRuntime().exec(command) + return process.inputStream + } +} diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/FolderRepository.kt b/app/core/src/main/java/com/fsck/k9/mailstore/FolderRepository.kt index 4629003a5a612e08507170d173af98eeda1dee21..f40136d34cc92743470f5526862f172d02815d64 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/FolderRepository.kt +++ b/app/core/src/main/java/com/fsck/k9/mailstore/FolderRepository.kt @@ -2,6 +2,10 @@ package com.fsck.k9.mailstore import com.fsck.k9.Account import com.fsck.k9.Account.FolderMode +import com.fsck.k9.DI +import com.fsck.k9.controller.MessagingController +import com.fsck.k9.controller.SimpleMessagingListener +import com.fsck.k9.helper.sendBlockingSilently import com.fsck.k9.mail.FolderClass import com.fsck.k9.preferences.AccountManager import kotlinx.coroutines.CoroutineDispatcher @@ -17,16 +21,11 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import com.fsck.k9.mail.FolderType as RemoteFolderType -import com.fsck.k9.DI -import com.fsck.k9.controller.MessagingController -import com.fsck.k9.controller.SimpleMessagingListener -import com.fsck.k9.helper.sendBlockingSilently @OptIn(ExperimentalCoroutinesApi::class) class FolderRepository( private val messageStoreManager: MessageStoreManager, private val accountManager: AccountManager, - private val account: Account, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO ) { private val sortForDisplay = diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/K9BackendStorage.kt b/app/core/src/main/java/com/fsck/k9/mailstore/K9BackendStorage.kt index 5fc159cbab12bf7c4de63a68bdd3a88d9a8b93bc..566a13ca454e32939123b73d77559417c19ab0d2 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/K9BackendStorage.kt +++ b/app/core/src/main/java/com/fsck/k9/mailstore/K9BackendStorage.kt @@ -60,7 +60,9 @@ class K9BackendStorage( } override fun deleteFolders(folderServerIds: List) { - messageStore.deleteFolders(folderServerIds) + if (folderServerIds.isNotEmpty()) { + messageStore.deleteFolders(folderServerIds) + } } override fun changeFolder(folderServerId: String, name: String, type: RemoteFolderType) { 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 337492294a630bad6f4fb849dc61ca4c8db9f773..94d653c9dd6f12c86ec39db3dcad94eb6f79f2ea 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 @@ -213,7 +213,6 @@ public class LocalFolder { } this.name = name; - updateFolderColumn("name", name); } @@ -247,11 +246,6 @@ public class LocalFolder { }); } - - public MessageCounts getMessageCounts() throws MessagingException { - return new MessageCounts(getUnreadMessageCount(), getStarredMessageCount()); - } - public int getUnreadMessageCount() throws MessagingException { if (databaseId == -1L) { open(); @@ -273,36 +267,11 @@ public class LocalFolder { cursor.close(); } - return unreadMessageCount; } }); } - private int getStarredMessageCount() throws MessagingException { - if (databaseId == -1L) { - open(); - } - - return this.localStore.getDatabase().execute(false, new DbCallback() { - @Override - public Integer doDbWork(final SQLiteDatabase db) { - int starredMessageCount = 0; - Cursor cursor = db.query("messages", new String[] { "COUNT(id)" }, - "folder_id = ? AND empty = 0 AND deleted = 0 AND flagged = 1", - new String[] { Long.toString(databaseId) }, null, null, null); - try { - if (cursor.moveToFirst()) { - starredMessageCount = cursor.getInt(0); - } - } finally { - cursor.close(); - } - return starredMessageCount; - } - }); - } - public int getVisibleLimit() throws MessagingException { open(); return visibleLimit; @@ -351,26 +320,14 @@ public class LocalFolder { return (FolderClass.INHERITED == syncClass) ? getDisplayClass() : syncClass; } - public FolderClass getRawSyncClass() { - return syncClass; - } - public FolderClass getNotifyClass() { return (FolderClass.INHERITED == notifyClass) ? getPushClass() : notifyClass; } - public FolderClass getRawNotifyClass() { - return notifyClass; - } - public FolderClass getPushClass() { return (FolderClass.INHERITED == pushClass) ? getSyncClass() : pushClass; } - public FolderClass getRawPushClass() { - return pushClass; - } - public void setDisplayClass(FolderClass displayClass) throws MessagingException { this.displayClass = displayClass; updateFolderColumn("display_class", this.displayClass.name()); @@ -572,11 +529,11 @@ public class LocalFolder { try { cursor = db.rawQuery( "SELECT " + - LocalStore.GET_MESSAGES_COLS + - "FROM messages " + - "LEFT JOIN message_parts ON (message_parts.id = messages.message_part_id) " + - "LEFT JOIN threads ON (threads.message_id = messages.id) " + - "WHERE uid = ? AND folder_id = ?", + LocalStore.GET_MESSAGES_COLS + + "FROM messages " + + "LEFT JOIN message_parts ON (message_parts.id = messages.message_part_id) " + + "LEFT JOIN threads ON (threads.message_id = messages.id) " + + "WHERE uid = ? AND folder_id = ?", new String[] { message.getUid(), Long.toString(databaseId) }); if (!cursor.moveToNext()) { @@ -633,12 +590,12 @@ public class LocalFolder { open(); return LocalFolder.this.localStore.getMessages(listener, LocalFolder.this, "SELECT " + LocalStore.GET_MESSAGES_COLS + - "FROM messages " + - "LEFT JOIN message_parts ON (message_parts.id = messages.message_part_id) " + - "LEFT JOIN threads ON (threads.message_id = messages.id) " + - "WHERE empty = 0 AND " + - (includeDeleted ? "" : "deleted = 0 AND ") + - "folder_id = ? ORDER BY date DESC", + "FROM messages " + + "LEFT JOIN message_parts ON (message_parts.id = messages.message_part_id) " + + "LEFT JOIN threads ON (threads.message_id = messages.id) " + + "WHERE empty = 0 AND " + + (includeDeleted ? "" : "deleted = 0 AND ") + + "folder_id = ? ORDER BY date DESC", new String[] { Long.toString(databaseId) }); } }); @@ -832,20 +789,15 @@ public class LocalFolder { } private long decodeAndCountBytes(InputStream rawInputStream, String encoding, long fallbackValue) { - InputStream decodingInputStream = localStore.getDecodingInputStream(rawInputStream, encoding); - try { - CountingOutputStream countingOutputStream = new CountingOutputStream(); - try { - IOUtils.copy(decodingInputStream, countingOutputStream); + try (InputStream decodingInputStream = localStore.getDecodingInputStream(rawInputStream, encoding)) { + try (CountingOutputStream countingOutputStream = new CountingOutputStream()) { + IOUtils.copy(decodingInputStream, countingOutputStream); return countingOutputStream.getCount(); - } catch (IOException e) { - return fallbackValue; } - } finally { - try { - decodingInputStream.close(); - } catch (IOException e) { /* ignore */ } + + } catch (IOException e) { + return fallbackValue; } } diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/LocalMessage.java b/app/core/src/main/java/com/fsck/k9/mailstore/LocalMessage.java index ca429acffc8818298d7de1b30232347376c0d45a..aad983dc93158540aff773d191f4ad69c2d60084 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/LocalMessage.java +++ b/app/core/src/main/java/com/fsck/k9/mailstore/LocalMessage.java @@ -24,7 +24,6 @@ import com.fsck.k9.mail.internet.AddressHeaderBuilder; import com.fsck.k9.mail.internet.MimeMessage; import com.fsck.k9.mail.message.MessageHeaderParser; import com.fsck.k9.mailstore.LockableDatabase.DbCallback; -import com.fsck.k9.mailstore.LockableDatabase.WrappedException; import com.fsck.k9.message.extractors.PreviewResult.PreviewType; import timber.log.Timber; @@ -380,7 +379,7 @@ public class LocalMessage extends MimeMessage { if (messageReference == null) { String accountUuid = getFolder().getAccountUuid(); long folderId = getFolder().getDatabaseId(); - messageReference = new MessageReference(accountUuid, folderId, mUid, null); + messageReference = new MessageReference(accountUuid, folderId, mUid); } return messageReference; } 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 bdeed866f5e6bf33decd7b6b2ed1e5248a95b8c4..98c3c8a90fae5b3ee27bc9ff81179c626147b04d 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 @@ -1,3 +1,4 @@ + package com.fsck.k9.mailstore; @@ -31,7 +32,7 @@ import android.text.TextUtils; import com.fsck.k9.Account; import com.fsck.k9.Clock; import com.fsck.k9.DI; -import com.fsck.k9.K9; +import com.fsck.k9.controller.MessageCounts; import com.fsck.k9.Preferences; import com.fsck.k9.controller.MessageCounts; import com.fsck.k9.controller.MessagingControllerCommands.PendingCommand; @@ -54,8 +55,6 @@ import com.fsck.k9.mailstore.LockableDatabase.SchemaDefinition; import com.fsck.k9.mailstore.StorageManager.InternalStorageProvider; import com.fsck.k9.message.extractors.AttachmentCounter; import com.fsck.k9.message.extractors.AttachmentInfoExtractor; -import com.fsck.k9.message.extractors.MessageFulltextCreator; -import com.fsck.k9.message.extractors.MessagePreviewCreator; import com.fsck.k9.provider.EmailProvider; import com.fsck.k9.provider.EmailProvider.MessageColumns; import com.fsck.k9.search.LocalSearch; @@ -183,8 +182,8 @@ public class LocalStore { } /** - * local://localhost/path/to/database/uuid.db This constructor is only used by {@link - * LocalStoreProvider#getInstance(Account)} + * local://localhost/path/to/database/uuid.db + * This constructor is only used by {@link LocalStoreProvider#getInstance(Account)} */ private LocalStore(final Account account, final Context context) throws MessagingException { this.context = context; @@ -276,7 +275,6 @@ public class LocalStore { } finally { Utility.closeQuietly(cursor); } - } }); @@ -696,22 +694,6 @@ public class LocalStore { return new File(attachmentDirectory, attachmentId); } - public String getFolderServerId(long folderId) throws MessagingException { - return database.execute(false, db -> { - try (Cursor cursor = db.query("folders", new String[] { "server_id" }, - "id = ?", new String[] { Long.toString(folderId) }, - null, null, null) - ) { - if (cursor.moveToFirst() && !cursor.isNull(0)) { - return cursor.getString(0); - } else { - throw new MessagingException("Folder not found by database ID: " + folderId, true); - } - } - }); - } - - public static class AttachmentInfo { public String name; public long size; @@ -809,7 +791,10 @@ public class LocalStore { database.execute(true, new DbCallback() { @Override public Void doDbWork(final SQLiteDatabase db) { - selectionArgs.toArray(new String[selectionArgs.size()]); + + selectionCallback.doDbWork(db, selection.toString(), + selectionArgs.toArray(new String[selectionArgs.size()])); + return null; } }); @@ -1086,7 +1071,6 @@ public class LocalStore { return new MessageCounts(getUnreadMessageCount(search), getStarredMessageCount(search)); } - public static String getColumnNameForFlag(Flag flag) { switch (flag) { case SEEN: { 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 e46b83333a2999d0637785993855d363a70ef2ba..bce1ec9b88252d809fe4614295bff9d9e5ed5b76 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 @@ -240,7 +240,6 @@ public class LockableDatabase { } private void openOrCreateDataspace() { - lockWrite(); try { final File databaseFile = prepareStorage(mStorageProviderId); @@ -276,11 +275,7 @@ public class LockableDatabase { } } - /** - * @param providerId Never null. - * @return DB file. - */ - protected File prepareStorage(final String providerId){ + protected File prepareStorage(final String providerId) { final StorageManager storageManager = getStorageManager(); final File databaseFile = storageManager.getDatabase(uUid, providerId); @@ -292,7 +287,6 @@ public class LockableDatabase { } if (!databaseParentDir.exists()) { if (!databaseParentDir.mkdirs()) { - // Android seems to be unmounting the storage... throw new RuntimeException("Unable to access: " + databaseParentDir); } FileHelper.touchFile(databaseParentDir, ".nomedia"); 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 6225792a9a60c9786246619db489ed85302f0d65..c7f290f47c30d03b9e7e3cf2b9fa9c2be2b5ef68 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 @@ -176,6 +176,11 @@ interface MessageStore { */ fun getFolderId(folderServerId: String): Long? + /** + * Find a folder with the given store ID and return its server ID. + */ + fun getFolderServerId(folderId: Long): String? + /** * Retrieve the number of messages in a folder. */ diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/MessageViewInfo.java b/app/core/src/main/java/com/fsck/k9/mailstore/MessageViewInfo.java index 35795fff237fdf17b5b5073c14d10b68a4dce8af..74e66529b86ee4407b6468dad3e222bcc0b18b6d 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/MessageViewInfo.java +++ b/app/core/src/main/java/com/fsck/k9/mailstore/MessageViewInfo.java @@ -66,7 +66,8 @@ public class MessageViewInfo { } public static MessageViewInfo createForMetadataOnly(Message message, boolean isMessageIncomplete) { - return new MessageViewInfo(message, isMessageIncomplete, null, null, false, null, null, null, null, null, null); + String subject = message.getSubject(); + return new MessageViewInfo(message, isMessageIncomplete, null, subject, false, null, null, null, null, null, null); } MessageViewInfo withCryptoData(CryptoResultAnnotation rootPartAnnotation, String extraViewableText, diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/StorageManager.java b/app/core/src/main/java/com/fsck/k9/mailstore/StorageManager.java index 92dcc381a16e0860ad1b62a9ed7f6fa58d634171..6aece52ec306c41a7142228f1f6e6ef5f45899ce 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/StorageManager.java +++ b/app/core/src/main/java/com/fsck/k9/mailstore/StorageManager.java @@ -6,27 +6,26 @@ import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import android.content.Context; import android.os.Environment; -import com.fsck.k9.CoreResourceProvider; -import com.fsck.k9.DI; - /** - * Manager for different {@link StorageProvider} -classes that abstract access to sd-cards, additional internal memory - * and other storage-locations. + * Manager for different {@link StorageProvider} -classes that abstract access + * to sd-cards, additional internal memory and other storage-locations. */ public class StorageManager { /** - * Provides entry points (File objects) to an underlying storage, alleviating the caller from having to know where - * that storage is located. + * Provides entry points (File objects) to an underlying storage, + * alleviating the caller from having to know where that storage is located. * *

- * Allow checking for the denoted storage availability since its lifecycle can evolving (a storage might become - * unavailable at some time and be back online later). + * Allow checking for the denoted storage availability since its lifecycle + * can evolving (a storage might become unavailable at some time and be back + * online later). *

*/ public interface StorageProvider { @@ -35,8 +34,9 @@ public class StorageManager { * Retrieve the uniquely identifier for the current implementation. * *

- * It is expected that the identifier doesn't change over reboots since it'll be used to save settings and - * retrieve the provider at a later time. + * It is expected that the identifier doesn't change over reboots since + * it'll be used to save settings and retrieve the provider at a later + * time. *

* *

@@ -50,56 +50,58 @@ public class StorageManager { /** * Hook point for provider initialization. * - * @param context Never null. + * @param context + * Never null. */ void init(Context context); /** - * @param context Never null. - * @return A user displayable, localized name for this provider. Never - * null. - */ - String getName(Context context); - - /** - * Some implementations may not be able to return valid File handles because the device doesn't provide the - * denoted storage. You can check the provider compatibility with this method to prevent from having to invoke - * this provider ever again. + * Some implementations may not be able to return valid File handles + * because the device doesn't provide the denoted storage. You can check + * the provider compatibility with this method to prevent from having to + * invoke this provider ever again. * - * @param context TODO + * @param context + * TODO * @return Whether this provider supports the current device. * @see StorageManager#getAvailableProviders() */ boolean isSupported(Context context); /** - * Return the {@link File} to the chosen email database file. The resulting {@link File} doesn't necessarily - * match an existing file on the filesystem. + * Return the {@link File} to the chosen email database file. The + * resulting {@link File} doesn't necessarily match an existing file on + * the filesystem. * - * @param context Never null. - * @param id Never null. + * @param context + * Never null. + * @param id + * Never null. * @return Never null. */ File getDatabase(Context context, String id); /** - * Return the {@link File} to the chosen attachment directory. The resulting {@link File} doesn't necessarily - * match an existing directory on the filesystem. + * Return the {@link File} to the chosen attachment directory. The + * resulting {@link File} doesn't necessarily match an existing + * directory on the filesystem. * - * @param context Never null. - * @param id Never null. + * @param context + * Never null. + * @param id + * Never null. * @return Never null. */ File getAttachmentDirectory(Context context, String id); - } /** * Strategy to access the always available internal storage. * *

- * This implementation is expected to work on every device since it's based on the regular Android API {@link - * Context#getDatabasePath(String)} and uses the result to retrieve the DB path and the attachment directory path. + * This implementation is expected to work on every device since it's based + * on the regular Android API {@link Context#getDatabasePath(String)} and + * uses the result to retrieve the DB path and the attachment directory path. *

* *

@@ -109,12 +111,6 @@ public class StorageManager { public static class InternalStorageProvider implements StorageProvider { public static final String ID = "InternalStorage"; - private final CoreResourceProvider resourceProvider; - - public InternalStorageProvider(CoreResourceProvider resourceProvider) { - this.resourceProvider = resourceProvider; - } - @Override public String getId() { return ID; @@ -122,12 +118,6 @@ public class StorageManager { @Override public void init(Context context) { - - } - - @Override - public String getName(Context context) { - return resourceProvider.internalStorageProviderName(); } @Override @@ -145,14 +135,14 @@ public class StorageManager { // we store attachments in the database directory return context.getDatabasePath(id + ".db_att"); } - - } /** - * Strategy for accessing the storage as returned by {@link Environment#getExternalStorageDirectory()}. In order to - * be compliant with Android recommendation regarding application uninstalling and to prevent from cluttering the - * storage root, the chosen directory will be + * Strategy for accessing the storage as returned by + * {@link Environment#getExternalStorageDirectory()}. In order to be + * compliant with Android recommendation regarding application uninstalling + * and to prevent from cluttering the storage root, the chosen directory + * will be * <STORAGE_ROOT>/Android/data/<APPLICATION_PACKAGE_NAME>/files/ * *

@@ -160,25 +150,20 @@ public class StorageManager { *

* *

- * This provider is expected to work on all devices but the returned underlying storage might not be always - * available, due to mount/unmount/USB share events. + * This provider is expected to work on all devices but the returned + * underlying storage might not be always available, due to + * mount/unmount/USB share events. *

*/ public static class ExternalStorageProvider implements StorageProvider { public static final String ID = "ExternalStorage"; - private final CoreResourceProvider resourceProvider; - /** * Chosen base directory. */ private File mApplicationDirectory; - public ExternalStorageProvider(CoreResourceProvider resourceProvider) { - this.resourceProvider = resourceProvider; - } - @Override public String getId() { return ID; @@ -189,11 +174,6 @@ public class StorageManager { mApplicationDirectory = context.getExternalFilesDir(null); } - @Override - public String getName(Context context) { - return resourceProvider.externalStorageProviderName(); - } - @Override public boolean isSupported(Context context) { return true; @@ -208,8 +188,6 @@ public class StorageManager { public File getAttachmentDirectory(Context context, String id) { return new File(mApplicationDirectory, id + ".db_att"); } - - } /** @@ -217,7 +195,6 @@ public class StorageManager { */ private final Map mProviders = new LinkedHashMap<>(); - protected final Context context; private static transient StorageManager instance; @@ -225,18 +202,18 @@ public class StorageManager { public static synchronized StorageManager getInstance(final Context context) { if (instance == null) { Context applicationContext = context.getApplicationContext(); - CoreResourceProvider resourceProvider = DI.get(CoreResourceProvider.class); - instance = new StorageManager(applicationContext, resourceProvider); + instance = new StorageManager(applicationContext); } return instance; } - /** - * @param context Never null. - * @throws NullPointerException If context is null. + * @param context + * Never null. + * @throws NullPointerException + * If context is null. */ - protected StorageManager(final Context context, CoreResourceProvider resourceProvider) throws NullPointerException { + protected StorageManager(final Context context) throws NullPointerException { if (context == null) { throw new NullPointerException("No Context given"); } @@ -253,8 +230,9 @@ public class StorageManager { * be considered as the default provider !!! */ final List allProviders = Arrays.asList( - new InternalStorageProvider(resourceProvider), - new ExternalStorageProvider(resourceProvider)); + new InternalStorageProvider(), + new ExternalStorageProvider() + ); for (final StorageProvider provider : allProviders) { // check for provider compatibility if (provider.isSupported(context)) { @@ -276,7 +254,8 @@ public class StorageManager { } /** - * @param providerId Never null. + * @param providerId + * Never null. * @return null if not found. */ protected StorageProvider getProvider(final String providerId) { @@ -284,8 +263,10 @@ public class StorageManager { } /** - * @param dbName Never null. - * @param providerId Never null. + * @param dbName + * Never null. + * @param providerId + * Never null. * @return The resolved database file for the given provider ID. */ public File getDatabase(final String dbName, final String providerId) { @@ -295,8 +276,10 @@ public class StorageManager { } /** - * @param dbName Never null. - * @param providerId Never null. + * @param dbName + * Never null. + * @param providerId + * Never null. * @return The resolved attachment directory for the given provider ID. */ public File getAttachmentDirectory(final String dbName, final String providerId) { @@ -305,18 +288,7 @@ public class StorageManager { return provider.getAttachmentDirectory(context, dbName); } - - /** - * @return A map of available providers names, indexed by their ID. Never - * null. - * @see StorageManager - * @see StorageProvider#isSupported(Context) - */ - public Map getAvailableProviders() { - final Map result = new LinkedHashMap<>(); - for (final Map.Entry entry : mProviders.entrySet()) { - result.put(entry.getKey(), entry.getValue().getName(context)); - } - return result; + public Set getAvailableProviders() { + return mProviders.keySet(); } } diff --git a/app/core/src/main/java/com/fsck/k9/message/IdentityHeaderBuilder.java b/app/core/src/main/java/com/fsck/k9/message/IdentityHeaderBuilder.java index a8f4472bf5fc37223172424e2b145452710086b7..92f1020bbc188a3ba4a849d5073c15930bda45be 100644 --- a/app/core/src/main/java/com/fsck/k9/message/IdentityHeaderBuilder.java +++ b/app/core/src/main/java/com/fsck/k9/message/IdentityHeaderBuilder.java @@ -9,9 +9,11 @@ import timber.log.Timber; import com.fsck.k9.Account.QuoteStyle; import com.fsck.k9.Identity; +import com.fsck.k9.K9; import com.fsck.k9.controller.MessageReference; import com.fsck.k9.mail.internet.TextBody; import com.fsck.k9.message.quote.InsertableHtmlContent; +import timber.log.Timber; public class IdentityHeaderBuilder { @@ -125,6 +127,7 @@ public class IdentityHeaderBuilder { headerValue.append(input, start, end); start = end; } + return headerValue.toString(); } diff --git a/app/core/src/main/java/com/fsck/k9/message/extractors/BasicPartInfoExtractor.kt b/app/core/src/main/java/com/fsck/k9/message/extractors/BasicPartInfoExtractor.kt index d8b2d830cedeaecec90189e1dd15a28cdf4411f8..6992a87a4a1bd344786e1e913afb5fd74f76f898 100644 --- a/app/core/src/main/java/com/fsck/k9/message/extractors/BasicPartInfoExtractor.kt +++ b/app/core/src/main/java/com/fsck/k9/message/extractors/BasicPartInfoExtractor.kt @@ -4,7 +4,6 @@ import com.fsck.k9.mail.Part import com.fsck.k9.mail.internet.MimeParameterDecoder import com.fsck.k9.mail.internet.MimeUtility import com.fsck.k9.mail.internet.MimeValue -import java.util.Locale private const val FALLBACK_NAME = "noname" diff --git a/app/core/src/main/java/com/fsck/k9/message/html/HtmlToPlainText.kt b/app/core/src/main/java/com/fsck/k9/message/html/HtmlToPlainText.kt index 191270718c05c1cb2520e76985861ba88b8900fb..9ea06eafe74b8c8152699a19d0ec6144bc15820d 100644 --- a/app/core/src/main/java/com/fsck/k9/message/html/HtmlToPlainText.kt +++ b/app/core/src/main/java/com/fsck/k9/message/html/HtmlToPlainText.kt @@ -22,7 +22,6 @@ object HtmlToPlainText { } private class FormattingVisitor : NodeVisitor { - private var width = 0 private val output = StringBuilder() private var collectLinkText = false private var linkText = StringBuilder() @@ -73,36 +72,11 @@ private class FormattingVisitor : NodeVisitor { } private fun append(text: String) { - if (text.startsWith("\n")) { - width = 0 - } - if (text == " " && (output.isEmpty() || output.last() in listOf(' ', '\n'))) { return } - if (text.length + width > MAX_WIDTH) { - val words = text.split(Regex("\\s+")) - for (i in words.indices) { - var word = words[i] - - val last = i == words.size - 1 - if (!last) { - word = "$word " - } - - if (word.length + width > MAX_WIDTH) { - output.append("\n").append(word) - width = word.length - } else { - output.append(word) - width += word.length - } - } - } else { - output.append(text) - width += text.length - } + output.append(text) } private fun startNewLine() { @@ -134,8 +108,4 @@ private class FormattingVisitor : NodeVisitor { return output.substring(0, lastIndex + 1) } - - companion object { - private const val MAX_WIDTH = 76 - } } diff --git a/app/core/src/main/java/com/fsck/k9/message/html/UriMatcher.kt b/app/core/src/main/java/com/fsck/k9/message/html/UriMatcher.kt index 4051fb352159e6e19d0c6d41d355e303156c0123..dd19edb4b3dc5411fa35c31e8d1c0645ef0f0a48 100644 --- a/app/core/src/main/java/com/fsck/k9/message/html/UriMatcher.kt +++ b/app/core/src/main/java/com/fsck/k9/message/html/UriMatcher.kt @@ -1,7 +1,5 @@ package com.fsck.k9.message.html -import java.util.Locale - object UriMatcher { private val SUPPORTED_URIS = run { val httpUriParser = HttpUriParser() diff --git a/app/core/src/main/java/com/fsck/k9/notification/AuthenticationErrorNotificationController.kt b/app/core/src/main/java/com/fsck/k9/notification/AuthenticationErrorNotificationController.kt new file mode 100644 index 0000000000000000000000000000000000000000..949994b52f9cd6f388333debb74ea0e5eb3fc7fd --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/AuthenticationErrorNotificationController.kt @@ -0,0 +1,59 @@ +package com.fsck.k9.notification + +import android.app.PendingIntent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.fsck.k9.Account + +internal open class AuthenticationErrorNotificationController( + private val notificationHelper: NotificationHelper, + private val actionCreator: NotificationActionCreator, + private val resourceProvider: NotificationResourceProvider +) { + fun showAuthenticationErrorNotification(account: Account, incoming: Boolean) { + val notificationId = NotificationIds.getAuthenticationErrorNotificationId(account, incoming) + val editServerSettingsPendingIntent = createContentIntent(account, incoming) + val title = resourceProvider.authenticationErrorTitle() + val text = resourceProvider.authenticationErrorBody(account.description) + + val notificationBuilder = notificationHelper + .createNotificationBuilder(account, NotificationChannelManager.ChannelType.MISCELLANEOUS) + .setSmallIcon(resourceProvider.iconWarning) + .setWhen(System.currentTimeMillis()) + .setAutoCancel(true) + .setTicker(title) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(editServerSettingsPendingIntent) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setCategory(NotificationCompat.CATEGORY_ERROR) + + notificationHelper.configureNotification( + builder = notificationBuilder, + ringtone = null, + vibrationPattern = null, + ledColor = NotificationHelper.NOTIFICATION_LED_FAILURE_COLOR, + ledSpeed = NotificationHelper.NOTIFICATION_LED_BLINK_FAST, + ringAndVibrate = true + ) + + notificationManager.notify(notificationId, notificationBuilder.build()) + } + + fun clearAuthenticationErrorNotification(account: Account, incoming: Boolean) { + val notificationId = NotificationIds.getAuthenticationErrorNotificationId(account, incoming) + notificationManager.cancel(notificationId) + } + + protected open fun createContentIntent(account: Account, incoming: Boolean): PendingIntent { + return if (incoming) { + actionCreator.getEditIncomingServerSettingsIntent(account) + } else { + actionCreator.getEditOutgoingServerSettingsIntent(account) + } + } + + private val notificationManager: NotificationManagerCompat + get() = notificationHelper.getNotificationManager() +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/BaseNotificationDataCreator.kt b/app/core/src/main/java/com/fsck/k9/notification/BaseNotificationDataCreator.kt new file mode 100644 index 0000000000000000000000000000000000000000..6b4501e9dda235b8182f9896c72bfc2cfd2fd9df --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/BaseNotificationDataCreator.kt @@ -0,0 +1,52 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.K9 +import com.fsck.k9.K9.LockScreenNotificationVisibility + +private const val MAX_NUMBER_OF_SENDERS_IN_LOCK_SCREEN_NOTIFICATION = 5 + +internal class BaseNotificationDataCreator { + + fun createBaseNotificationData(notificationData: NotificationData): BaseNotificationData { + val account = notificationData.account + return BaseNotificationData( + account = account, + groupKey = NotificationGroupKeys.getGroupKey(account), + accountName = getAccountName(account), + color = account.chipColor, + newMessagesCount = notificationData.newMessagesCount, + lockScreenNotificationData = createLockScreenNotificationData(notificationData), + appearance = createNotificationAppearance(account) + ) + } + + private fun getAccountName(account: Account): String { + val accountDescription = account.description?.takeIf { it.isNotEmpty() } + return accountDescription ?: account.email + } + + private fun createLockScreenNotificationData(data: NotificationData): LockScreenNotificationData { + return when (K9.lockScreenNotificationVisibility) { + LockScreenNotificationVisibility.NOTHING -> LockScreenNotificationData.None + LockScreenNotificationVisibility.APP_NAME -> LockScreenNotificationData.AppName + LockScreenNotificationVisibility.EVERYTHING -> LockScreenNotificationData.Public + LockScreenNotificationVisibility.MESSAGE_COUNT -> LockScreenNotificationData.MessageCount + LockScreenNotificationVisibility.SENDERS -> LockScreenNotificationData.SenderNames(getSenderNames(data)) + } + } + + private fun getSenderNames(data: NotificationData): String { + return data.getContentForSummaryNotification().asSequence() + .map { it.sender } + .distinct() + .take(MAX_NUMBER_OF_SENDERS_IN_LOCK_SCREEN_NOTIFICATION) + .joinToString() + } + + private fun createNotificationAppearance(account: Account): NotificationAppearance { + return with(account.notificationSetting) { + NotificationAppearance(ringtone = ringtone, vibrationPattern = vibration, ledColor = ledColor) + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/CertificateErrorNotificationController.kt b/app/core/src/main/java/com/fsck/k9/notification/CertificateErrorNotificationController.kt new file mode 100644 index 0000000000000000000000000000000000000000..5dff412d39a7d5d8980595e99b0b879edca4b945 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/CertificateErrorNotificationController.kt @@ -0,0 +1,59 @@ +package com.fsck.k9.notification + +import android.app.PendingIntent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.fsck.k9.Account + +internal open class CertificateErrorNotificationController( + private val notificationHelper: NotificationHelper, + private val actionCreator: NotificationActionCreator, + private val resourceProvider: NotificationResourceProvider +) { + fun showCertificateErrorNotification(account: Account, incoming: Boolean) { + val notificationId = NotificationIds.getCertificateErrorNotificationId(account, incoming) + val editServerSettingsPendingIntent = createContentIntent(account, incoming) + val title = resourceProvider.certificateErrorTitle(account.description) + val text = resourceProvider.certificateErrorBody() + + val notificationBuilder = notificationHelper + .createNotificationBuilder(account, NotificationChannelManager.ChannelType.MISCELLANEOUS) + .setSmallIcon(resourceProvider.iconWarning) + .setWhen(System.currentTimeMillis()) + .setAutoCancel(true) + .setTicker(title) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(editServerSettingsPendingIntent) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setCategory(NotificationCompat.CATEGORY_ERROR) + + notificationHelper.configureNotification( + builder = notificationBuilder, + ringtone = null, + vibrationPattern = null, + ledColor = NotificationHelper.NOTIFICATION_LED_FAILURE_COLOR, + ledSpeed = NotificationHelper.NOTIFICATION_LED_BLINK_FAST, + ringAndVibrate = true + ) + + notificationManager.notify(notificationId, notificationBuilder.build()) + } + + fun clearCertificateErrorNotifications(account: Account, incoming: Boolean) { + val notificationId = NotificationIds.getCertificateErrorNotificationId(account, incoming) + notificationManager.cancel(notificationId) + } + + protected open fun createContentIntent(account: Account, incoming: Boolean): PendingIntent { + return if (incoming) { + actionCreator.getEditIncomingServerSettingsIntent(account) + } else { + actionCreator.getEditOutgoingServerSettingsIntent(account) + } + } + + private val notificationManager: NotificationManagerCompat + get() = notificationHelper.getNotificationManager() +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/CoreKoinModule.kt b/app/core/src/main/java/com/fsck/k9/notification/CoreKoinModule.kt index 6904c91255921af2b0857da4dc647a9d04091690..928a2d6fee675ccfdabe99d6b62fd60d5c135651 100644 --- a/app/core/src/main/java/com/fsck/k9/notification/CoreKoinModule.kt +++ b/app/core/src/main/java/com/fsck/k9/notification/CoreKoinModule.kt @@ -8,15 +8,23 @@ import java.util.concurrent.Executors import org.koin.dsl.module val coreNotificationModule = module { - single { NotificationController(get(), get(), get(), get(), get()) } + single { + NotificationController( + certificateErrorNotificationController = get(), + authenticationErrorNotificationController = get(), + syncNotificationController = get(), + sendFailedNotificationController = get(), + newMailNotificationController = get() + ) + } single { NotificationManagerCompat.from(get()) } - single { NotificationHelper(get(), get(), get()) } + single { NotificationHelper(context = get(), notificationManager = get(), channelUtils = get()) } single { NotificationChannelManager( - get(), - Executors.newSingleThreadExecutor(), - get().getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager, - get() + preferences = get(), + backgroundExecutor = Executors.newSingleThreadExecutor(), + notificationManager = get().getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager, + resourceProvider = get() ) } single { @@ -26,15 +34,67 @@ val coreNotificationModule = module { serverSettingsSerializer = get() ) } - single { CertificateErrorNotifications(get(), get(), get()) } - single { AuthenticationErrorNotifications(get(), get(), get()) } - single { SyncNotifications(get(), get(), get()) } - single { SendFailedNotifications(get(), get(), get()) } - single { NewMailNotifications(get(), get(), get(), get()) } - single { NotificationContentCreator(get(), get()) } - single { WearNotifications(get(), get(), get()) } - single { DeviceNotifications(get(), get(), get(), get(), get()) } - single { LockScreenNotification(get(), get()) } + single { + CertificateErrorNotificationController( + notificationHelper = get(), + actionCreator = get(), + resourceProvider = get() + ) + } + single { + AuthenticationErrorNotificationController( + notificationHelper = get(), + actionCreator = get(), + resourceProvider = get() + ) + } + single { + SyncNotificationController(notificationHelper = get(), actionBuilder = get(), resourceProvider = get()) + } + single { + SendFailedNotificationController(notificationHelper = get(), actionBuilder = get(), resourceProvider = get()) + } + single { + NewMailNotificationController( + notificationManager = get(), + newMailNotificationManager = get(), + summaryNotificationCreator = get(), + singleMessageNotificationCreator = get() + ) + } + single { + NewMailNotificationManager( + contentCreator = get(), + baseNotificationDataCreator = get(), + singleMessageNotificationDataCreator = get(), + summaryNotificationDataCreator = get(), + clock = get() + ) + } + factory { NotificationContentCreator(context = get(), resourceProvider = get()) } + factory { BaseNotificationDataCreator() } + factory { SingleMessageNotificationDataCreator() } + factory { SummaryNotificationDataCreator(singleMessageNotificationDataCreator = get()) } + factory { + SingleMessageNotificationCreator( + notificationHelper = get(), + actionCreator = get(), + resourceProvider = get(), + lockScreenNotificationCreator = get(), + notificationManager = get() + ) + } + factory { + SummaryNotificationCreator( + notificationHelper = get(), + actionCreator = get(), + lockScreenNotificationCreator = get(), + singleMessageNotificationCreator = get(), + resourceProvider = get(), + notificationManager = get() + ) + } + factory { LockScreenNotificationCreator(notificationHelper = get(), resourceProvider = get()) } single { PushNotificationManager( context = get(), diff --git a/app/core/src/main/java/com/fsck/k9/notification/DeviceNotifications.kt b/app/core/src/main/java/com/fsck/k9/notification/DeviceNotifications.kt deleted file mode 100644 index 5a6315be3d486a6ce93727cee5115dc9fe67317d..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/notification/DeviceNotifications.kt +++ /dev/null @@ -1,210 +0,0 @@ -package com.fsck.k9.notification - -import android.app.KeyguardManager -import android.app.Notification -import android.content.Context -import androidx.core.app.NotificationCompat -import com.fsck.k9.Account -import com.fsck.k9.K9 -import com.fsck.k9.K9.NotificationHideSubject -import com.fsck.k9.K9.NotificationQuickDelete -import com.fsck.k9.notification.NotificationGroupKeys.getGroupKey -import com.fsck.k9.notification.NotificationIds.getNewMailSummaryNotificationId - -internal open class DeviceNotifications( - notificationHelper: NotificationHelper, - actionCreator: NotificationActionCreator, - private val lockScreenNotification: LockScreenNotification, - private val wearNotifications: WearNotifications, - resourceProvider: NotificationResourceProvider -) : BaseNotifications(notificationHelper, actionCreator, resourceProvider) { - - fun buildSummaryNotification(account: Account, notificationData: NotificationData, silent: Boolean): Notification { - val unreadMessageCount = notificationData.unreadMessageCount - - val builder = when { - isPrivacyModeActive -> { - createSimpleSummaryNotification(account, unreadMessageCount) - } - notificationData.isSingleMessageNotification -> { - val holder = notificationData.holderForLatestNotification - createBigTextStyleSummaryNotification(account, holder) - } - else -> { - createInboxStyleSummaryNotification(account, notificationData, unreadMessageCount) - } - } - - if (notificationData.containsStarredMessages()) { - builder.priority = NotificationCompat.PRIORITY_HIGH - } - val notificationId = getNewMailSummaryNotificationId(account) - val deletePendingIntent = actionCreator.createDismissAllMessagesPendingIntent(account, notificationId) - builder.setDeleteIntent(deletePendingIntent) - - lockScreenNotification.configureLockScreenNotification(builder, notificationData) - - val notificationSetting = account.notificationSetting - notificationHelper.configureNotification( - builder = builder, - ringtone = if (notificationSetting.isRingEnabled) notificationSetting.ringtone else null, - vibrationPattern = if (notificationSetting.isVibrateEnabled) notificationSetting.vibration else null, - ledColor = if (notificationSetting.isLedEnabled) notificationSetting.ledColor else null, - ledSpeed = NotificationHelper.NOTIFICATION_LED_BLINK_SLOW, - ringAndVibrate = !silent - ) - - return builder.build() - } - - private fun createSimpleSummaryNotification(account: Account, unreadMessageCount: Int): NotificationCompat.Builder { - val accountName = notificationHelper.getAccountName(account) - val newMailText = resourceProvider.newMailTitle() - val unreadMessageCountText = resourceProvider.newMailUnreadMessageCount(unreadMessageCount, accountName) - val notificationId = getNewMailSummaryNotificationId(account) - val contentIntent = actionCreator.createViewFolderListPendingIntent(account, notificationId) - - return createAndInitializeNotificationBuilder(account) - .setNumber(unreadMessageCount) - .setTicker(newMailText) - .setContentTitle(unreadMessageCountText) - .setContentText(newMailText) - .setContentIntent(contentIntent) - } - - private fun createBigTextStyleSummaryNotification( - account: Account, - holder: NotificationHolder - ): NotificationCompat.Builder { - val notificationId = getNewMailSummaryNotificationId(account) - val builder = createBigTextStyleNotification(account, holder, notificationId) - builder.setGroupSummary(true) - - val content = holder.content - addReplyAction(builder, content, notificationId) - addMarkAsReadAction(builder, content, notificationId) - addDeleteAction(builder, content, notificationId) - - return builder - } - - private fun createInboxStyleSummaryNotification( - account: Account, - notificationData: NotificationData, - unreadMessageCount: Int - ): NotificationCompat.Builder { - val latestNotification = notificationData.holderForLatestNotification - val newMessagesCount = notificationData.newMessagesCount - val accountName = notificationHelper.getAccountName(account) - val title = resourceProvider.newMessagesTitle(newMessagesCount) - val summary = if (notificationData.hasSummaryOverflowMessages()) { - resourceProvider.additionalMessages(notificationData.getSummaryOverflowMessagesCount(), accountName) - } else { - accountName - } - val groupKey = getGroupKey(account) - - val builder = createAndInitializeNotificationBuilder(account) - .setNumber(unreadMessageCount) - .setTicker(latestNotification.content.summary) - .setGroup(groupKey) - .setGroupSummary(true) - .setContentTitle(title) - .setSubText(accountName) - - val style = createInboxStyle(builder) - .setBigContentTitle(title) - .setSummaryText(summary) - - for (content in notificationData.getContentForSummaryNotification()) { - style.addLine(content.summary) - } - builder.setStyle(style) - addMarkAllAsReadAction(builder, notificationData) - addDeleteAllAction(builder, notificationData) - wearNotifications.addSummaryActions(builder, notificationData) - val notificationId = getNewMailSummaryNotificationId(account) - val messageReferences = notificationData.getAllMessageReferences() - val contentIntent = actionCreator.createViewMessagesPendingIntent(account, messageReferences, notificationId) - builder.setContentIntent(contentIntent) - - return builder - } - - private fun addMarkAsReadAction( - builder: NotificationCompat.Builder, - content: NotificationContent, - notificationId: Int - ) { - val icon = resourceProvider.iconMarkAsRead - val title = resourceProvider.actionMarkAsRead() - val messageReference = content.messageReference - val action = actionCreator.createMarkMessageAsReadPendingIntent(messageReference, notificationId) - - builder.addAction(icon, title, action) - } - - private fun addMarkAllAsReadAction(builder: NotificationCompat.Builder, notificationData: NotificationData) { - val icon = resourceProvider.iconMarkAsRead - val title = resourceProvider.actionMarkAsRead() - val account = notificationData.account - val messageReferences = notificationData.getAllMessageReferences() - val notificationId = getNewMailSummaryNotificationId(account) - val markAllAsReadPendingIntent = - actionCreator.createMarkAllAsReadPendingIntent(account, messageReferences, notificationId) - - builder.addAction(icon, title, markAllAsReadPendingIntent) - } - - private fun addDeleteAllAction(builder: NotificationCompat.Builder, notificationData: NotificationData) { - if (K9.notificationQuickDeleteBehaviour !== NotificationQuickDelete.ALWAYS) { - return - } - val icon = resourceProvider.iconDelete - val title = resourceProvider.actionDelete() - val account = notificationData.account - val notificationId = getNewMailSummaryNotificationId(account) - val messageReferences = notificationData.getAllMessageReferences() - val action = actionCreator.createDeleteAllPendingIntent(account, messageReferences, notificationId) - builder.addAction(icon, title, action) - } - - private fun addDeleteAction( - builder: NotificationCompat.Builder, - content: NotificationContent, - notificationId: Int - ) { - if (!isDeleteActionEnabled()) { - return - } - val icon = resourceProvider.iconDelete - val title = resourceProvider.actionDelete() - val messageReference = content.messageReference - val action = actionCreator.createDeleteMessagePendingIntent(messageReference, notificationId) - - builder.addAction(icon, title, action) - } - - private fun addReplyAction(builder: NotificationCompat.Builder, content: NotificationContent, notificationId: Int) { - val icon = resourceProvider.iconReply - val title = resourceProvider.actionReply() - val messageReference = content.messageReference - val replyToMessagePendingIntent = actionCreator.createReplyPendingIntent(messageReference, notificationId) - - builder.addAction(icon, title, replyToMessagePendingIntent) - } - - private val isPrivacyModeActive: Boolean - get() { - val keyguardService = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager - val privacyModeAlwaysEnabled = K9.notificationHideSubject === NotificationHideSubject.ALWAYS - val privacyModeEnabledWhenLocked = K9.notificationHideSubject === NotificationHideSubject.WHEN_LOCKED - val screenLocked = keyguardService.inKeyguardRestrictedInputMode() - return privacyModeAlwaysEnabled || privacyModeEnabledWhenLocked && screenLocked - } - - protected open fun createInboxStyle(builder: NotificationCompat.Builder?): NotificationCompat.InboxStyle { - return NotificationCompat.InboxStyle(builder) - } -} - diff --git a/app/core/src/main/java/com/fsck/k9/notification/LockScreenNotification.kt b/app/core/src/main/java/com/fsck/k9/notification/LockScreenNotification.kt index f13e21f3150e661c8f011fd65799b3270a6aca37..cc0462b72c8077942222505440718a10fef4966d 100644 --- a/app/core/src/main/java/com/fsck/k9/notification/LockScreenNotification.kt +++ b/app/core/src/main/java/com/fsck/k9/notification/LockScreenNotification.kt @@ -57,14 +57,13 @@ internal class LockScreenNotification( private fun createPublicNotification(notificationData: NotificationData): NotificationCompat.Builder { val account = notificationData.account - val newMessages = notificationData.newMessagesCount - val unreadCount = notificationData.unreadMessageCount - val title = resourceProvider.newMessagesTitle(newMessages) + val newMessagesCount = notificationData.newMessagesCount + val title = resourceProvider.newMessagesTitle(newMessagesCount) return notificationHelper.createNotificationBuilder(account, NotificationChannelManager.ChannelType.MESSAGES) .setSmallIcon(resourceProvider.iconNewMail) .setColor(account.chipColor) - .setNumber(unreadCount) + .setNumber(newMessagesCount) .setContentTitle(title) .setCategory(NotificationCompat.CATEGORY_EMAIL) } @@ -80,4 +79,4 @@ internal class LockScreenNotification( companion object { const val MAX_NUMBER_OF_SENDERS_IN_LOCK_SCREEN_NOTIFICATION = 5 } -} \ No newline at end of file +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/LockScreenNotificationCreator.kt b/app/core/src/main/java/com/fsck/k9/notification/LockScreenNotificationCreator.kt new file mode 100644 index 0000000000000000000000000000000000000000..e071201c0dac8659ebff93614f9a332094354f85 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/LockScreenNotificationCreator.kt @@ -0,0 +1,60 @@ +package com.fsck.k9.notification + +import android.app.Notification +import androidx.core.app.NotificationCompat + +internal class LockScreenNotificationCreator( + private val notificationHelper: NotificationHelper, + private val resourceProvider: NotificationResourceProvider +) { + fun configureLockScreenNotification( + builder: NotificationCompat.Builder, + baseNotificationData: BaseNotificationData + ) { + when (baseNotificationData.lockScreenNotificationData) { + LockScreenNotificationData.None -> { + builder.setVisibility(NotificationCompat.VISIBILITY_SECRET) + } + LockScreenNotificationData.AppName -> { + builder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + } + LockScreenNotificationData.Public -> { + builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + } + is LockScreenNotificationData.SenderNames -> { + val publicNotification = createPublicNotificationWithSenderList(baseNotificationData) + builder.setPublicVersion(publicNotification) + } + LockScreenNotificationData.MessageCount -> { + val publicNotification = createPublicNotificationWithNewMessagesCount(baseNotificationData) + builder.setPublicVersion(publicNotification) + } + } + } + + private fun createPublicNotificationWithSenderList(baseNotificationData: BaseNotificationData): Notification { + val notificationData = baseNotificationData.lockScreenNotificationData as LockScreenNotificationData.SenderNames + return createPublicNotification(baseNotificationData) + .setContentText(notificationData.senderNames) + .build() + } + + private fun createPublicNotificationWithNewMessagesCount(baseNotificationData: BaseNotificationData): Notification { + return createPublicNotification(baseNotificationData) + .setContentText(baseNotificationData.accountName) + .build() + } + + private fun createPublicNotification(baseNotificationData: BaseNotificationData): NotificationCompat.Builder { + val account = baseNotificationData.account + val newMessagesCount = baseNotificationData.newMessagesCount + val title = resourceProvider.newMessagesTitle(newMessagesCount) + + return notificationHelper.createNotificationBuilder(account, NotificationChannelManager.ChannelType.MESSAGES) + .setSmallIcon(resourceProvider.iconNewMail) + .setColor(baseNotificationData.color) + .setNumber(newMessagesCount) + .setContentTitle(title) + .setCategory(NotificationCompat.CATEGORY_EMAIL) + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/MessageSummaryNotifications.kt b/app/core/src/main/java/com/fsck/k9/notification/MessageSummaryNotifications.kt deleted file mode 100644 index d83d2913feaa3f2adfaf8475d98beaa6adc5f774..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/notification/MessageSummaryNotifications.kt +++ /dev/null @@ -1,187 +0,0 @@ -package com.fsck.k9.notification - -import android.app.Notification -import androidx.core.app.NotificationCompat -import com.fsck.k9.Account -import com.fsck.k9.K9 -import com.fsck.k9.K9.NotificationQuickDelete -import com.fsck.k9.notification.NotificationGroupKeys.getGroupKey -import com.fsck.k9.notification.NotificationIds.getNewMailSummaryNotificationId - -internal open class MessageSummaryNotifications( - notificationHelper: NotificationHelper, - actionCreator: NotificationActionCreator, - private val lockScreenNotification: LockScreenNotification, - private val singleMessageNotifications: SingleMessageNotifications, - resourceProvider: NotificationResourceProvider -) : BaseNotifications(notificationHelper, actionCreator, resourceProvider) { - fun buildSummaryNotification(account: Account, notificationData: NotificationData, silent: Boolean): Notification { - val unreadMessageCount = notificationData.unreadMessageCount - - val builder = when { - notificationData.isSingleMessageNotification -> { - val holder = notificationData.holderForLatestNotification - createSingleMessageNotification(account, holder) - } - else -> { - createInboxStyleSummaryNotification(account, notificationData, unreadMessageCount) - } - } - if (notificationData.containsStarredMessages()) { - builder.priority = NotificationCompat.PRIORITY_HIGH - } - val notificationId = getNewMailSummaryNotificationId(account) - val deletePendingIntent = actionCreator.createDismissAllMessagesPendingIntent(account, notificationId) - builder.setDeleteIntent(deletePendingIntent) - lockScreenNotification.configureLockScreenNotification(builder, notificationData) - val notificationSetting = account.notificationSetting - notificationHelper.configureNotification( - builder = builder, - ringtone = if (notificationSetting.isRingEnabled) notificationSetting.ringtone else null, - vibrationPattern = if (notificationSetting.isVibrateEnabled) notificationSetting.vibration else null, - ledColor = if (notificationSetting.isLedEnabled) notificationSetting.ledColor else null, - ledSpeed = NotificationHelper.NOTIFICATION_LED_BLINK_SLOW, - ringAndVibrate = !silent - ) - return builder.build() - } - - private fun createSingleMessageNotification( - account: Account, - holder: NotificationHolder - ): NotificationCompat.Builder { - val notificationId = getNewMailSummaryNotificationId(account) - val builder = singleMessageNotifications.createSingleMessageNotificationBuilder(account, holder, notificationId) - builder.setGroupSummary(true) - return builder - } - - private fun createInboxStyleSummaryNotification( - account: Account, - notificationData: NotificationData, - unreadMessageCount: Int - ): NotificationCompat.Builder { - val latestNotification = notificationData.holderForLatestNotification - val newMessagesCount = notificationData.newMessagesCount - val accountName = notificationHelper.getAccountName(account) - val title = resourceProvider.newMessagesTitle(newMessagesCount) - val summary = if (notificationData.hasSummaryOverflowMessages()) { - resourceProvider.additionalMessages(notificationData.getSummaryOverflowMessagesCount(), accountName) - } else { - accountName - } - val groupKey = getGroupKey(account) - val builder = createAndInitializeNotificationBuilder(account) - .setNumber(unreadMessageCount) - .setTicker(latestNotification.content.summary) - .setGroup(groupKey) - .setGroupSummary(true) - .setContentTitle(title) - .setSubText(accountName) - val style = createInboxStyle(builder) - .setBigContentTitle(title) - .setSummaryText(summary) - for (content in notificationData.getContentForSummaryNotification()) { - style.addLine(content.summary) - } - builder.setStyle(style) - addMarkAllAsReadAction(builder, notificationData) - addDeleteAllAction(builder, notificationData) - addWearActions(builder, notificationData) - val notificationId = getNewMailSummaryNotificationId(account) - val messageReferences = notificationData.getAllMessageReferences() - val contentIntent = actionCreator.createViewMessagesPendingIntent(account, messageReferences, notificationId) - builder.setContentIntent(contentIntent) - return builder - } - - private fun addMarkAllAsReadAction(builder: NotificationCompat.Builder, notificationData: NotificationData) { - val icon = resourceProvider.iconMarkAsRead - val title = resourceProvider.actionMarkAsRead() - val account = notificationData.account - val messageReferences = notificationData.getAllMessageReferences() - val notificationId = getNewMailSummaryNotificationId(account) - val markAllAsReadPendingIntent = - actionCreator.createMarkAllAsReadPendingIntent(account, messageReferences, notificationId) - builder.addAction(icon, title, markAllAsReadPendingIntent) - } - - private fun addDeleteAllAction(builder: NotificationCompat.Builder, notificationData: NotificationData) { - if (K9.notificationQuickDeleteBehaviour !== NotificationQuickDelete.ALWAYS) { - return - } - val icon = resourceProvider.iconDelete - val title = resourceProvider.actionDelete() - val account = notificationData.account - val notificationId = getNewMailSummaryNotificationId(account) - val messageReferences = notificationData.getAllMessageReferences() - val action = actionCreator.createDeleteAllPendingIntent(account, messageReferences, notificationId) - builder.addAction(icon, title, action) - } - - private fun addWearActions(builder: NotificationCompat.Builder, notificationData: NotificationData) { - val wearableExtender = NotificationCompat.WearableExtender() - addMarkAllAsReadWearAction(wearableExtender, notificationData) - if (isDeleteActionAvailableForWear()) { - addDeleteAllWearAction(wearableExtender, notificationData) - } - if (isArchiveActionAvailableForWear(notificationData.account)) { - addArchiveAllWearAction(wearableExtender, notificationData) - } - builder.extend(wearableExtender) - } - - private fun addMarkAllAsReadWearAction( - wearableExtender: NotificationCompat.WearableExtender, - notificationData: NotificationData - ) { - val icon = resourceProvider.wearIconMarkAsRead - val title = resourceProvider.actionMarkAllAsRead() - val account = notificationData.account - val messageReferences = notificationData.getAllMessageReferences() - val notificationId = getNewMailSummaryNotificationId(account) - val action = actionCreator.createMarkAllAsReadPendingIntent(account, messageReferences, notificationId) - val markAsReadAction = NotificationCompat.Action.Builder(icon, title, action).build() - wearableExtender.addAction(markAsReadAction) - } - - private fun addDeleteAllWearAction( - wearableExtender: NotificationCompat.WearableExtender, - notificationData: NotificationData - ) { - val icon = resourceProvider.wearIconDelete - val title = resourceProvider.actionDeleteAll() - val account = notificationData.account - val messageReferences = notificationData.getAllMessageReferences() - val notificationId = getNewMailSummaryNotificationId(account) - val action = actionCreator.createDeleteAllPendingIntent(account, messageReferences, notificationId) - val deleteAction = NotificationCompat.Action.Builder(icon, title, action).build() - wearableExtender.addAction(deleteAction) - } - - private fun addArchiveAllWearAction( - wearableExtender: NotificationCompat.WearableExtender, - notificationData: NotificationData - ) { - val icon = resourceProvider.wearIconArchive - val title = resourceProvider.actionArchiveAll() - val account = notificationData.account - val messageReferences = notificationData.getAllMessageReferences() - val notificationId = getNewMailSummaryNotificationId(account) - val action = actionCreator.createArchiveAllPendingIntent(account, messageReferences, notificationId) - val archiveAction = NotificationCompat.Action.Builder(icon, title, action).build() - wearableExtender.addAction(archiveAction) - } - - private fun isDeleteActionAvailableForWear(): Boolean { - return isDeleteActionEnabled() && !K9.isConfirmDeleteFromNotification - } - - private fun isArchiveActionAvailableForWear(account: Account): Boolean { - return account.archiveFolderId != null - } - - protected open fun createInboxStyle(builder: NotificationCompat.Builder?): NotificationCompat.InboxStyle { - return NotificationCompat.InboxStyle(builder) - } -} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationController.kt b/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationController.kt new file mode 100644 index 0000000000000000000000000000000000000000..f6ecbd510ff727725f7a0a6043ab4158be72eda6 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationController.kt @@ -0,0 +1,68 @@ +package com.fsck.k9.notification + +import androidx.core.app.NotificationManagerCompat +import com.fsck.k9.Account +import com.fsck.k9.controller.MessageReference +import com.fsck.k9.mailstore.LocalMessage + +/** + * Handle notifications for new messages. + */ +internal class NewMailNotificationController( + private val notificationManager: NotificationManagerCompat, + private val newMailNotificationManager: NewMailNotificationManager, + private val summaryNotificationCreator: SummaryNotificationCreator, + private val singleMessageNotificationCreator: SingleMessageNotificationCreator +) { + fun addNewMailNotification(account: Account, message: LocalMessage, silent: Boolean) { + val notificationData = newMailNotificationManager.addNewMailNotification(account, message, silent) + + processNewMailNotificationData(notificationData) + } + + fun removeNewMailNotification(account: Account, messageReference: MessageReference) { + val notificationData = newMailNotificationManager.removeNewMailNotification(account, messageReference) + + if (notificationData != null) { + processNewMailNotificationData(notificationData) + } + } + + fun clearNewMailNotifications(account: Account) { + val cancelNotificationIds = newMailNotificationManager.clearNewMailNotifications(account) + + cancelNotifications(cancelNotificationIds) + } + + private fun processNewMailNotificationData(notificationData: NewMailNotificationData) { + cancelNotifications(notificationData.cancelNotificationIds) + + for (singleNotificationData in notificationData.singleNotificationData) { + createSingleNotification(notificationData.baseNotificationData, singleNotificationData) + } + + notificationData.summaryNotificationData?.let { summaryNotificationData -> + createSummaryNotification(notificationData.baseNotificationData, summaryNotificationData) + } + } + + private fun cancelNotifications(notificationIds: List) { + for (notificationId in notificationIds) { + notificationManager.cancel(notificationId) + } + } + + private fun createSingleNotification( + baseNotificationData: BaseNotificationData, + singleNotificationData: SingleNotificationData + ) { + singleMessageNotificationCreator.createSingleNotification(baseNotificationData, singleNotificationData) + } + + private fun createSummaryNotification( + baseNotificationData: BaseNotificationData, + summaryNotificationData: SummaryNotificationData + ) { + summaryNotificationCreator.createSummaryNotification(baseNotificationData, summaryNotificationData) + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationData.kt b/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationData.kt new file mode 100644 index 0000000000000000000000000000000000000000..9069b4f85ada8b1d1e66f855afff48b7df5df598 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationData.kt @@ -0,0 +1,87 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.controller.MessageReference + +internal data class NewMailNotificationData( + val cancelNotificationIds: List, + val baseNotificationData: BaseNotificationData, + val singleNotificationData: List, + val summaryNotificationData: SummaryNotificationData? +) + +internal data class BaseNotificationData( + val account: Account, + val accountName: String, + val groupKey: String, + val color: Int, + val newMessagesCount: Int, + val lockScreenNotificationData: LockScreenNotificationData, + val appearance: NotificationAppearance +) + +internal sealed interface LockScreenNotificationData { + object None : LockScreenNotificationData + object AppName : LockScreenNotificationData + object Public : LockScreenNotificationData + object MessageCount : LockScreenNotificationData + data class SenderNames(val senderNames: String) : LockScreenNotificationData +} + +internal data class NotificationAppearance( + val ringtone: String?, + val vibrationPattern: LongArray?, + val ledColor: Int? +) + +internal data class SingleNotificationData( + val notificationId: Int, + val isSilent: Boolean, + val timestamp: Long, + val content: NotificationContent, + val actions: List, + val wearActions: List, + val addLockScreenNotification: Boolean +) + +internal sealed interface SummaryNotificationData + +internal data class SummarySingleNotificationData( + val singleNotificationData: SingleNotificationData +) : SummaryNotificationData + +internal data class SummaryInboxNotificationData( + val notificationId: Int, + val isSilent: Boolean, + val timestamp: Long, + val content: List, + val additionalMessagesCount: Int, + val messageReferences: List, + val actions: List, + val wearActions: List +) : SummaryNotificationData + +internal enum class NotificationAction { + Reply, + MarkAsRead, + Delete +} + +internal enum class WearNotificationAction { + Reply, + MarkAsRead, + Delete, + Archive, + Spam +} + +internal enum class SummaryNotificationAction { + MarkAsRead, + Delete +} + +internal enum class SummaryWearNotificationAction { + MarkAsRead, + Delete, + Archive +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationManager.kt b/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationManager.kt new file mode 100644 index 0000000000000000000000000000000000000000..99796dc9b4c911b6a6feeb7215d9149a32db2ef3 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationManager.kt @@ -0,0 +1,143 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.Clock +import com.fsck.k9.controller.MessageReference +import com.fsck.k9.mailstore.LocalMessage + +/** + * Manages notifications for new messages + */ +internal class NewMailNotificationManager( + private val contentCreator: NotificationContentCreator, + private val baseNotificationDataCreator: BaseNotificationDataCreator, + private val singleMessageNotificationDataCreator: SingleMessageNotificationDataCreator, + private val summaryNotificationDataCreator: SummaryNotificationDataCreator, + private val clock: Clock +) { + private val notifications = mutableMapOf() + private val lock = Any() + + fun addNewMailNotification(account: Account, message: LocalMessage, silent: Boolean): NewMailNotificationData { + val content = contentCreator.createFromMessage(account, message) + + synchronized(lock) { + val notificationData = getOrCreateNotificationData(account) + val result = notificationData.addNotificationContent(content) + + val singleNotificationData = createSingleNotificationData( + account = account, + notificationId = result.notificationHolder.notificationId, + content = result.notificationHolder.content, + addLockScreenNotification = notificationData.isSingleMessageNotification + ) + + return NewMailNotificationData( + cancelNotificationIds = if (result.shouldCancelNotification) { + listOf(result.notificationId) + } else { + emptyList() + }, + baseNotificationData = createBaseNotificationData(notificationData), + singleNotificationData = listOf(singleNotificationData), + summaryNotificationData = createSummaryNotificationData(notificationData, silent) + ) + } + } + + fun removeNewMailNotification(account: Account, messageReference: MessageReference): NewMailNotificationData? { + synchronized(lock) { + val notificationData = getNotificationData(account) ?: return null + + val result = notificationData.removeNotificationForMessage(messageReference) + if (result.isUnknownNotification) return null + + if (notificationData.newMessagesCount == 0) { + return NewMailNotificationData( + cancelNotificationIds = listOf( + NotificationIds.getNewMailSummaryNotificationId(account), + result.notificationId + ), + baseNotificationData = createBaseNotificationData(notificationData), + singleNotificationData = emptyList(), + summaryNotificationData = null + ) + } + + val singleNotificationData = if (result.shouldCreateNotification) { + val singleNotificationData = createSingleNotificationData( + account = account, + notificationId = result.notificationHolder.notificationId, + content = result.notificationHolder.content, + addLockScreenNotification = notificationData.isSingleMessageNotification + ) + listOf(singleNotificationData) + } else { + emptyList() + } + + return NewMailNotificationData( + cancelNotificationIds = listOf(result.notificationId), + baseNotificationData = createBaseNotificationData(notificationData), + singleNotificationData = singleNotificationData, + summaryNotificationData = createSummaryNotificationData(notificationData, silent = true) + ) + } + } + + fun clearNewMailNotifications(account: Account): List { + synchronized(lock) { + val notificationData = removeNotificationData(account) ?: return emptyList() + return notificationData.getActiveNotificationIds() + + NotificationIds.getNewMailSummaryNotificationId(account) + } + } + + private fun createBaseNotificationData(notificationData: NotificationData): BaseNotificationData { + return baseNotificationDataCreator.createBaseNotificationData(notificationData) + } + + private fun createSingleNotificationData( + account: Account, + notificationId: Int, + content: NotificationContent, + addLockScreenNotification: Boolean + ): SingleNotificationData { + return singleMessageNotificationDataCreator.createSingleNotificationData( + account, + notificationId, + content, + timestamp = now(), + addLockScreenNotification + ) + } + + private fun createSummaryNotificationData(data: NotificationData, silent: Boolean): SummaryNotificationData { + return summaryNotificationDataCreator.createSummaryNotificationData(data, timestamp = now(), silent) + } + + private fun getOrCreateNotificationData(account: Account): NotificationData { + val notificationData = getNotificationData(account) + if (notificationData != null) return notificationData + + val accountNumber = account.accountNumber + val newNotificationHolder = NotificationData(account) + notifications[accountNumber] = newNotificationHolder + + return newNotificationHolder + } + + private fun getNotificationData(account: Account): NotificationData? { + val accountNumber = account.accountNumber + return notifications[accountNumber] + } + + private fun removeNotificationData(account: Account): NotificationData? { + val accountNumber = account.accountNumber + val notificationData = notifications[accountNumber] + notifications.remove(accountNumber) + return notificationData + } + + private fun now(): Long = clock.time +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NewMailNotifications.kt b/app/core/src/main/java/com/fsck/k9/notification/NewMailNotifications.kt deleted file mode 100644 index 42170c4baf8b9209c09dfa35a521be5785584f2f..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/notification/NewMailNotifications.kt +++ /dev/null @@ -1,107 +0,0 @@ -package com.fsck.k9.notification - -import android.util.SparseArray -import androidx.core.app.NotificationManagerCompat -import com.fsck.k9.Account -import com.fsck.k9.controller.MessageReference -import com.fsck.k9.mailstore.LocalMessage - -/** - * Handle notifications for new messages. - */ -internal open class NewMailNotifications( - private val notificationHelper: NotificationHelper, - private val contentCreator: NotificationContentCreator, - private val messageSummaryNotifications: MessageSummaryNotifications, - private val singleMessageNotifications: SingleMessageNotifications -) { - private val notifications = SparseArray() - private val lock = Any() - fun addNewMailNotification(account: Account, message: LocalMessage, unreadMessageCount: Int, silent: Boolean) { - val content = contentCreator.createFromMessage(account, message) - synchronized(lock) { - val notificationData = getOrCreateNotificationData(account, unreadMessageCount) - val result = notificationData.addNotificationContent(content) - if (result.shouldCancelNotification) { - val notificationId = result.notificationId - cancelNotification(notificationId) - } - createSingleMessageNotification(account, result.notificationHolder) - createSummaryNotification(account, notificationData, silent) - } - } - - fun removeNewMailNotification(account: Account, messageReference: MessageReference) { - synchronized(lock) { - val notificationData = getNotificationData(account) ?: return - val result = notificationData.removeNotificationForMessage(messageReference) - if (result.isUnknownNotification) return - cancelNotification(result.notificationId) - if (result.shouldCreateNotification) { - createSingleMessageNotification(account, result.notificationHolder) - } - updateSummaryNotification(account, notificationData) - } - } - - fun clearNewMailNotifications(account: Account) { - val notificationData = synchronized(lock) { removeNotificationData(account) } ?: return - for (notificationId in notificationData.getActiveNotificationIds()) { - cancelNotification(notificationId) - } - val notificationId = NotificationIds.getNewMailSummaryNotificationId(account) - cancelNotification(notificationId) - } - - private fun getOrCreateNotificationData(account: Account, unreadMessageCount: Int): NotificationData { - val notificationData = getNotificationData(account) - if (notificationData != null) return notificationData - val accountNumber = account.accountNumber - val newNotificationHolder = createNotificationData(account, unreadMessageCount) - notifications.put(accountNumber, newNotificationHolder) - return newNotificationHolder - } - - private fun getNotificationData(account: Account): NotificationData? { - val accountNumber = account.accountNumber - return notifications[accountNumber] - } - - private fun removeNotificationData(account: Account): NotificationData? { - val accountNumber = account.accountNumber - val notificationData = notifications[accountNumber] - notifications.remove(accountNumber) - return notificationData - } - - protected open fun createNotificationData(account: Account, unreadMessageCount: Int): NotificationData { - return NotificationData(account, unreadMessageCount) - } - - private fun cancelNotification(notificationId: Int) { - notificationManager.cancel(notificationId) - } - - private fun updateSummaryNotification(account: Account, notificationData: NotificationData) { - if (notificationData.newMessagesCount == 0) { - clearNewMailNotifications(account) - } else { - createSummaryNotification(account, notificationData, silent = true) - } - } - - private fun createSummaryNotification(account: Account, notificationData: NotificationData, silent: Boolean) { - val notification = messageSummaryNotifications.buildSummaryNotification(account, notificationData, silent) - val notificationId = NotificationIds.getNewMailSummaryNotificationId(account) - notificationManager.notify(notificationId, notification) - } - - private fun createSingleMessageNotification(account: Account, holder: NotificationHolder) { - val notification = singleMessageNotifications.buildSingleMessageNotification(account, holder) - val notificationId = holder.notificationId - notificationManager.notify(notificationId, notification) - } - - private val notificationManager: NotificationManagerCompat - get() = notificationHelper.getNotificationManager() -} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationActionCreator.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationActionCreator.kt index 851cfec33f8071eda9d8927eccf5a51e64ae64b6..d7e403b8c315bbd1d65326ab97bddfd161bbc6d0 100644 --- a/app/core/src/main/java/com/fsck/k9/notification/NotificationActionCreator.kt +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationActionCreator.kt @@ -1,23 +1,25 @@ package com.fsck.k9.notification import android.app.PendingIntent -import android.content.Context import com.fsck.k9.Account import com.fsck.k9.controller.MessageReference interface NotificationActionCreator { fun createViewMessagePendingIntent(messageReference: MessageReference, notificationId: Int): PendingIntent + fun createViewFolderPendingIntent(account: Account, folderId: Long, notificationId: Int): PendingIntent + fun createViewMessagesPendingIntent( account: Account, messageReferences: List, notificationId: Int ): PendingIntent + fun createViewFolderListPendingIntent(account: Account, notificationId: Int): PendingIntent + fun createDismissAllMessagesPendingIntent(account: Account, notificationId: Int): PendingIntent fun createDismissMessagePendingIntent( - context: Context, messageReference: MessageReference, notificationId: Int ): PendingIntent @@ -54,4 +56,3 @@ interface NotificationActionCreator { fun createMarkMessageAsSpamPendingIntent(messageReference: MessageReference, notificationId: Int): PendingIntent } - diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationActionService.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationActionService.kt index a91bf70439edc0da6a3eac738d2c40cd595d2011..d70ea87778326e3c891c99868bf21dc387dd459c 100644 --- a/app/core/src/main/java/com/fsck/k9/notification/NotificationActionService.kt +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationActionService.kt @@ -36,6 +36,7 @@ class NotificationActionService : Service() { ACTION_SPAM -> markMessageAsSpam(intent, account) ACTION_DISMISS -> Timber.i("Notification dismissed") } + cancelNotifications(intent, account) return START_NOT_STICKY @@ -47,8 +48,10 @@ class NotificationActionService : Service() { private fun markMessagesAsRead(intent: Intent, account: Account) { Timber.i("NotificationActionService marking messages as read") + val messageReferenceStrings = intent.getStringArrayListExtra(EXTRA_MESSAGE_REFERENCES) val messageReferences = MessageReferenceHelper.toMessageReferenceList(messageReferenceStrings) + for (messageReference in messageReferences) { val folderId = messageReference.folderId val uid = messageReference.uid @@ -58,16 +61,22 @@ class NotificationActionService : Service() { private fun deleteMessages(intent: Intent) { Timber.i("NotificationActionService deleting messages") + + val messageReferenceStrings = intent.getStringArrayListExtra(EXTRA_MESSAGE_REFERENCES) + val messageReferences = MessageReferenceHelper.toMessageReferenceList(messageReferenceStrings) + messagingController.deleteMessages(messageReferences) } private fun archiveMessages(intent: Intent, account: Account) { Timber.i("NotificationActionService archiving messages") + val archiveFolderId = account.archiveFolderId if (archiveFolderId == null || !messagingController.isMoveCapable(account)) { Timber.w("Cannot archive messages") return } + val messageReferenceStrings = intent.getStringArrayListExtra(EXTRA_MESSAGE_REFERENCES) val messageReferences = MessageReferenceHelper.toMessageReferenceList(messageReferenceStrings) @@ -81,6 +90,7 @@ class NotificationActionService : Service() { private fun markMessageAsSpam(intent: Intent, account: Account) { Timber.i("NotificationActionService moving messages to spam") + val messageReferenceString = intent.getStringExtra(EXTRA_MESSAGE_REFERENCE) val messageReference = MessageReference.parse(messageReferenceString) @@ -88,6 +98,7 @@ class NotificationActionService : Service() { Timber.w("Invalid message reference: %s", messageReferenceString) return } + val spamFolderId = account.spamFolderId if (spamFolderId == null) { Timber.w("No spam folder configured") @@ -104,6 +115,7 @@ class NotificationActionService : Service() { if (intent.hasExtra(EXTRA_MESSAGE_REFERENCE)) { val messageReferenceString = intent.getStringExtra(EXTRA_MESSAGE_REFERENCE) val messageReference = MessageReference.parse(messageReferenceString) + if (messageReference != null) { messagingController.cancelNotificationForMessage(account, messageReference) } else { @@ -112,6 +124,7 @@ class NotificationActionService : Service() { } else if (intent.hasExtra(EXTRA_MESSAGE_REFERENCES)) { val messageReferenceStrings = intent.getStringArrayListExtra(EXTRA_MESSAGE_REFERENCES) val messageReferences = MessageReferenceHelper.toMessageReferenceList(messageReferenceStrings) + for (messageReference in messageReferences) { messagingController.cancelNotificationForMessage(account, messageReference) } @@ -229,4 +242,3 @@ class NotificationActionService : Service() { } } } - diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationContent.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationContent.kt index 715317387c264f6bac0e2727b816f9b55c8a1f06..374979d2e101a7d45b7a878d7d0244b1646bf318 100644 --- a/app/core/src/main/java/com/fsck/k9/notification/NotificationContent.kt +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationContent.kt @@ -2,11 +2,10 @@ package com.fsck.k9.notification import com.fsck.k9.controller.MessageReference -internal class NotificationContent( +internal data class NotificationContent( val messageReference: MessageReference, val sender: String, val subject: String, val preview: CharSequence, - val summary: CharSequence, - val isStarred: Boolean -) \ No newline at end of file + val summary: CharSequence +) diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationContentCreator.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationContentCreator.kt index 3685c31ab948ad484409098a5676bc7b455da127..86d85b8efa8e3e81e06f079752228d61a40d2286 100644 --- a/app/core/src/main/java/com/fsck/k9/notification/NotificationContentCreator.kt +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationContentCreator.kt @@ -6,7 +6,6 @@ import com.fsck.k9.Account import com.fsck.k9.K9 import com.fsck.k9.helper.Contacts import com.fsck.k9.helper.MessageHelper -import com.fsck.k9.mail.Flag import com.fsck.k9.mail.Message import com.fsck.k9.mailstore.LocalMessage import com.fsck.k9.message.extractors.PreviewResult.PreviewType @@ -23,8 +22,7 @@ internal class NotificationContentCreator( sender = getMessageSenderForDisplay(sender), subject = getMessageSubject(message), preview = getMessagePreview(message), - summary = buildMessageSummary(sender, getMessageSubject(message)), - isStarred = message.isSet(Flag.FLAGGED) + summary = buildMessageSummary(sender, getMessageSubject(message)) ) } @@ -74,6 +72,7 @@ internal class NotificationContentCreator( private fun getMessageSender(account: Account, message: Message): String? { val contacts = if (K9.isShowContactName) Contacts.getInstance(context) else null var isSelf = false + val fromAddresses = message.from if (!fromAddresses.isNullOrEmpty()) { isSelf = account.isAnIdentity(fromAddresses) diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationController.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationController.kt index 4fa4c0c4f4b2a56606166f57c2ef76cc4f24afa0..d915648dc0c4fca96e5280290d511139b246a509 100644 --- a/app/core/src/main/java/com/fsck/k9/notification/NotificationController.kt +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationController.kt @@ -6,70 +6,65 @@ import com.fsck.k9.mailstore.LocalFolder import com.fsck.k9.mailstore.LocalMessage class NotificationController internal constructor( - private val certificateErrorNotifications: CertificateErrorNotifications, - private val authenticationErrorNotifications: AuthenticationErrorNotifications, - private val syncNotifications: SyncNotifications, - private val sendFailedNotifications: SendFailedNotifications, - private val newMailNotifications: NewMailNotifications + private val certificateErrorNotificationController: CertificateErrorNotificationController, + private val authenticationErrorNotificationController: AuthenticationErrorNotificationController, + private val syncNotificationController: SyncNotificationController, + private val sendFailedNotificationController: SendFailedNotificationController, + private val newMailNotificationController: NewMailNotificationController ) { fun showCertificateErrorNotification(account: Account, incoming: Boolean) { - certificateErrorNotifications.showCertificateErrorNotification(account, incoming) + certificateErrorNotificationController.showCertificateErrorNotification(account, incoming) } fun clearCertificateErrorNotifications(account: Account, incoming: Boolean) { - certificateErrorNotifications.clearCertificateErrorNotifications(account, incoming) + certificateErrorNotificationController.clearCertificateErrorNotifications(account, incoming) } fun showAuthenticationErrorNotification(account: Account, incoming: Boolean) { - authenticationErrorNotifications.showAuthenticationErrorNotification(account, incoming) + authenticationErrorNotificationController.showAuthenticationErrorNotification(account, incoming) } fun clearAuthenticationErrorNotification(account: Account, incoming: Boolean) { - authenticationErrorNotifications.clearAuthenticationErrorNotification(account, incoming) + authenticationErrorNotificationController.clearAuthenticationErrorNotification(account, incoming) } fun showSendingNotification(account: Account) { - syncNotifications.showSendingNotification(account) + syncNotificationController.showSendingNotification(account) } fun clearSendingNotification(account: Account) { - syncNotifications.clearSendingNotification(account) + syncNotificationController.clearSendingNotification(account) } fun showSendFailedNotification(account: Account, exception: Exception) { - sendFailedNotifications.showSendFailedNotification(account, exception) + sendFailedNotificationController.showSendFailedNotification(account, exception) } fun clearSendFailedNotification(account: Account) { - sendFailedNotifications.clearSendFailedNotification(account) + sendFailedNotificationController.clearSendFailedNotification(account) } fun showFetchingMailNotification(account: Account, folder: LocalFolder) { - syncNotifications.showFetchingMailNotification(account, folder) + syncNotificationController.showFetchingMailNotification(account, folder) } fun showEmptyFetchingMailNotification(account: Account) { - syncNotifications.showEmptyFetchingMailNotification(account) + syncNotificationController.showEmptyFetchingMailNotification(account) } fun clearFetchingMailNotification(account: Account) { - syncNotifications.clearFetchingMailNotification(account) + syncNotificationController.clearFetchingMailNotification(account) } - fun addNewMailNotification( - account: Account, - message: LocalMessage, - previousUnreadMessageCount: Int, - silent: Boolean - ) { - newMailNotifications.addNewMailNotification(account, message, previousUnreadMessageCount, silent) + fun addNewMailNotification(account: Account, message: LocalMessage, silent: Boolean) { + newMailNotificationController.addNewMailNotification(account, message, silent) } fun removeNewMailNotification(account: Account, messageReference: MessageReference) { - newMailNotifications.removeNewMailNotification(account, messageReference) + newMailNotificationController.removeNewMailNotification(account, messageReference) } fun clearNewMailNotifications(account: Account) { - newMailNotifications.clearNewMailNotifications(account) + newMailNotificationController.clearNewMailNotifications(account) } -} \ No newline at end of file +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationData.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationData.kt index c153ed9cd30c98446549f964e3bc0cb6abfad9e3..9487b1fe901b679723f35b2316a6184391385322 100644 --- a/app/core/src/main/java/com/fsck/k9/notification/NotificationData.kt +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationData.kt @@ -1,17 +1,16 @@ package com.fsck.k9.notification -import android.util.SparseBooleanArray import com.fsck.k9.Account import com.fsck.k9.controller.MessageReference import java.util.LinkedList -internal class NotificationData(val account: Account, private val initialUnreadMessageCount: Int) { +/** + * A holder class for pending new mail notifications. + */ +internal class NotificationData(val account: Account) { private val activeNotifications = LinkedList() private val additionalNotifications = LinkedList() - private val notificationIdsInUse = SparseBooleanArray() - - val unreadMessageCount: Int - get() = initialUnreadMessageCount + newMessagesCount + private val notificationIdsInUse = mutableMapOf() val newMessagesCount: Int get() = activeNotifications.size + additionalNotifications.size @@ -23,7 +22,7 @@ internal class NotificationData(val account: Account, private val initialUnreadM get() = activeNotifications.first private val isMaxNumberOfActiveNotificationsReached: Boolean - get() = activeNotifications.size == MAX_NUMBER_OF_STACKED_NOTIFICATIONS + get() = activeNotifications.size == MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS fun addNotificationContent(content: NotificationContent): AddNotificationResult { val notificationId: Int @@ -53,8 +52,8 @@ internal class NotificationData(val account: Account, private val initialUnreadM } private fun getNewNotificationId(): Int { - for (index in 0 until MAX_NUMBER_OF_STACKED_NOTIFICATIONS) { - val notificationId = NotificationIds.getNewMailStackedNotificationId(account, index) + for (index in 0 until MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS) { + val notificationId = NotificationIds.getSingleMessageNotificationId(account, index) if (!isNotificationInUse(notificationId)) { markNotificationIdAsInUse(notificationId) return notificationId @@ -65,25 +64,21 @@ internal class NotificationData(val account: Account, private val initialUnreadM } private fun isNotificationInUse(notificationId: Int): Boolean { - return notificationIdsInUse[notificationId] + return notificationIdsInUse[notificationId] ?: false } private fun markNotificationIdAsInUse(notificationId: Int) { - notificationIdsInUse.put(notificationId, true) + notificationIdsInUse[notificationId] = true } private fun markNotificationIdAsFree(notificationId: Int) { - notificationIdsInUse.delete(notificationId) + notificationIdsInUse.remove(notificationId) } private fun createNotificationHolder(notificationId: Int, content: NotificationContent): NotificationHolder { return NotificationHolder(notificationId, content) } - fun containsStarredMessages(): Boolean { - return activeNotifications.any { it.content.isStarred } || additionalNotifications.any { it.isStarred } - } - fun hasSummaryOverflowMessages(): Boolean { return activeNotifications.size > MAX_NUMBER_OF_MESSAGES_FOR_SUMMARY_NOTIFICATION } @@ -104,8 +99,8 @@ internal class NotificationData(val account: Account, private val initialUnreadM .toList() } - fun getActiveNotificationIds(): IntArray { - return activeNotifications.map { it.notificationId }.toIntArray() + fun getActiveNotificationIds(): List { + return activeNotifications.map { it.notificationId } } fun removeNotificationForMessage(messageReference: MessageReference): RemoveNotificationResult { @@ -116,6 +111,7 @@ internal class NotificationData(val account: Account, private val initialUnreadM val notificationId = holder.notificationId markNotificationIdAsFree(notificationId) + return if (additionalNotifications.isEmpty()) { RemoveNotificationResult.cancelNotification(notificationId) } else { @@ -137,15 +133,20 @@ internal class NotificationData(val account: Account, private val initialUnreadM for (holder in activeNotifications) { messageReferences.add(holder.content.messageReference) } + for (content in additionalNotifications) { messageReferences.add(content.messageReference) } + return messageReferences } companion object { // Note: As of Jellybean, phone notifications show a maximum of 5 lines, while tablet notifications show 7 lines. const val MAX_NUMBER_OF_MESSAGES_FOR_SUMMARY_NOTIFICATION = 5 - const val MAX_NUMBER_OF_STACKED_NOTIFICATIONS = 8 + + // Note: This class assumes that + // MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS >= MAX_NUMBER_OF_MESSAGES_FOR_SUMMARY_NOTIFICATION + const val MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS = 8 } -} \ No newline at end of file +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationGroupKeys.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationGroupKeys.kt index 8aa3255f660680dc4fe9aab3b2a1874e0ed3e292..14c150cd27f39c63ef98ac34177ba43f828bbfa8 100644 --- a/app/core/src/main/java/com/fsck/k9/notification/NotificationGroupKeys.kt +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationGroupKeys.kt @@ -9,4 +9,4 @@ object NotificationGroupKeys { fun getGroupKey(account: Account): String { return NOTIFICATION_GROUP_KEY_PREFIX + account.accountNumber } -} \ No newline at end of file +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationHelper.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationHelper.kt index e8045884ff0ca3d87647321aee0c418863d71dee..9199fa7ad08dd15ac8700c9c2ff05639d55084b4 100644 --- a/app/core/src/main/java/com/fsck/k9/notification/NotificationHelper.kt +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationHelper.kt @@ -35,7 +35,7 @@ class NotificationHelper( if (vibrationPattern != null) { builder.setVibrate(vibrationPattern) } - }else { + } else { builder.setNotificationSilent() } @@ -78,8 +78,8 @@ class NotificationHelper( } companion object { - private const val NOTIFICATION_LED_ON_TIME = 500 - private const val NOTIFICATION_LED_OFF_TIME = 2000 + internal const val NOTIFICATION_LED_ON_TIME = 500 + internal const val NOTIFICATION_LED_OFF_TIME = 2000 private const val NOTIFICATION_LED_FAST_ON_TIME = 100 private const val NOTIFICATION_LED_FAST_OFF_TIME = 100 @@ -88,3 +88,28 @@ class NotificationHelper( internal const val NOTIFICATION_LED_FAILURE_COLOR = -0x10000 } } + +internal fun NotificationCompat.Builder.setAppearance( + silent: Boolean, + appearance: NotificationAppearance +): NotificationCompat.Builder = apply { + if (silent) { + setSilent(true) + } else { + if (!appearance.ringtone.isNullOrEmpty()) { + setSound(Uri.parse(appearance.ringtone)) + } + + if (appearance.vibrationPattern != null) { + setVibrate(appearance.vibrationPattern) + } + + if (appearance.ledColor != null) { + setLights( + appearance.ledColor, + NotificationHelper.NOTIFICATION_LED_ON_TIME, + NotificationHelper.NOTIFICATION_LED_OFF_TIME + ) + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationHolder.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationHolder.kt index d89293576465bad98eaf4d1827076209628d9b49..d60dfb635abc9e7a971f02bb8c8c96e585f49b29 100644 --- a/app/core/src/main/java/com/fsck/k9/notification/NotificationHolder.kt +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationHolder.kt @@ -3,4 +3,4 @@ package com.fsck.k9.notification internal class NotificationHolder( val notificationId: Int, val content: NotificationContent -) \ No newline at end of file +) diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationIds.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationIds.kt index fd0c5d2cba53ce5e5b0bf339f461dafd1bebe595..9a2504b193d5f780224159f90fbb405d2bb30baf 100644 --- a/app/core/src/main/java/com/fsck/k9/notification/NotificationIds.kt +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationIds.kt @@ -13,11 +13,11 @@ internal object NotificationIds { private const val OFFSET_AUTHENTICATION_ERROR_OUTGOING = 4 private const val OFFSET_FETCHING_MAIL = 5 private const val OFFSET_NEW_MAIL_SUMMARY = 6 - private const val OFFSET_NEW_MAIL_STACKED = 7 - private const val NUMBER_OF_DEVICE_NOTIFICATIONS = 7 - private const val NUMBER_OF_STACKED_NOTIFICATIONS = NotificationData.MAX_NUMBER_OF_STACKED_NOTIFICATIONS + private const val OFFSET_NEW_MAIL_SINGLE = 7 + private const val NUMBER_OF_MISC_ACCOUNT_NOTIFICATIONS = 7 + private const val NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS = NotificationData.MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS private const val NUMBER_OF_NOTIFICATIONS_PER_ACCOUNT = - NUMBER_OF_DEVICE_NOTIFICATIONS + NUMBER_OF_STACKED_NOTIFICATIONS + NUMBER_OF_MISC_ACCOUNT_NOTIFICATIONS + NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS @JvmStatic fun getNewMailSummaryNotificationId(account: Account): Int { @@ -25,10 +25,10 @@ internal object NotificationIds { } @JvmStatic - fun getNewMailStackedNotificationId(account: Account, index: Int): Int { - require(index in 0 until NUMBER_OF_STACKED_NOTIFICATIONS) { "Invalid index: $index" } + fun getSingleMessageNotificationId(account: Account, index: Int): Int { + require(index in 0 until NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS) { "Invalid index: $index" } - return getBaseNotificationId(account) + OFFSET_NEW_MAIL_STACKED + index + return getBaseNotificationId(account) + OFFSET_NEW_MAIL_SINGLE + index } @JvmStatic diff --git a/app/core/src/main/java/com/fsck/k9/notification/SendFailedNotificationController.kt b/app/core/src/main/java/com/fsck/k9/notification/SendFailedNotificationController.kt new file mode 100644 index 0000000000000000000000000000000000000000..957c3be0d1ff4d9737a0f4b357c25e0b7df22bc3 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/SendFailedNotificationController.kt @@ -0,0 +1,54 @@ +package com.fsck.k9.notification + +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.fsck.k9.Account +import com.fsck.k9.helper.ExceptionHelper + +internal class SendFailedNotificationController( + private val notificationHelper: NotificationHelper, + private val actionBuilder: NotificationActionCreator, + private val resourceProvider: NotificationResourceProvider +) { + fun showSendFailedNotification(account: Account, exception: Exception) { + val title = resourceProvider.sendFailedTitle() + val text = ExceptionHelper.getRootCauseMessage(exception) + + val notificationId = NotificationIds.getSendFailedNotificationId(account) + val folderListPendingIntent = actionBuilder.createViewFolderListPendingIntent( + account, notificationId + ) + + val notificationBuilder = notificationHelper + .createNotificationBuilder(account, NotificationChannelManager.ChannelType.MISCELLANEOUS) + .setSmallIcon(resourceProvider.iconWarning) + .setWhen(System.currentTimeMillis()) + .setAutoCancel(true) + .setTicker(title) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(folderListPendingIntent) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setCategory(NotificationCompat.CATEGORY_ERROR) + + notificationHelper.configureNotification( + builder = notificationBuilder, + ringtone = null, + vibrationPattern = null, + ledColor = NotificationHelper.NOTIFICATION_LED_FAILURE_COLOR, + ledSpeed = NotificationHelper.NOTIFICATION_LED_BLINK_FAST, + ringAndVibrate = true + ) + + notificationManager.notify(notificationId, notificationBuilder.build()) + } + + fun clearSendFailedNotification(account: Account) { + val notificationId = NotificationIds.getSendFailedNotificationId(account) + notificationManager.cancel(notificationId) + } + + private val notificationManager: NotificationManagerCompat + get() = notificationHelper.getNotificationManager() +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/SingleMessageNotificationCreator.kt b/app/core/src/main/java/com/fsck/k9/notification/SingleMessageNotificationCreator.kt new file mode 100644 index 0000000000000000000000000000000000000000..46635307a418863fb74749631861464da3d8384b --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/SingleMessageNotificationCreator.kt @@ -0,0 +1,185 @@ +package com.fsck.k9.notification + +import android.app.PendingIntent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.WearableExtender +import androidx.core.app.NotificationManagerCompat +import com.fsck.k9.notification.NotificationChannelManager.ChannelType +import androidx.core.app.NotificationCompat.Builder as NotificationBuilder + +internal class SingleMessageNotificationCreator( + private val notificationHelper: NotificationHelper, + private val actionCreator: NotificationActionCreator, + private val resourceProvider: NotificationResourceProvider, + private val lockScreenNotificationCreator: LockScreenNotificationCreator, + private val notificationManager: NotificationManagerCompat +) { + fun createSingleNotification( + baseNotificationData: BaseNotificationData, + singleNotificationData: SingleNotificationData, + isGroupSummary: Boolean = false + ) { + val account = baseNotificationData.account + val notificationId = singleNotificationData.notificationId + val content = singleNotificationData.content + + val notification = notificationHelper.createNotificationBuilder(account, ChannelType.MESSAGES) + .setCategory(NotificationCompat.CATEGORY_EMAIL) + .setAutoCancel(true) + .setGroup(baseNotificationData.groupKey) + .setGroupSummary(isGroupSummary) + .setSmallIcon(resourceProvider.iconNewMail) + .setColor(baseNotificationData.color) + .setWhen(singleNotificationData.timestamp) + .setTicker(content.summary) + .setContentTitle(content.sender) + .setContentText(content.subject) + .setSubText(baseNotificationData.accountName) + .setBigText(content.preview) + .setContentIntent(createViewIntent(content, notificationId)) + .setDeleteIntent(createDismissIntent(content, notificationId)) + .setDeviceActions(singleNotificationData) + .setWearActions(singleNotificationData) + .setAppearance(singleNotificationData.isSilent, baseNotificationData.appearance) + .setLockScreenNotification(baseNotificationData, singleNotificationData.addLockScreenNotification) + .build() + + notificationManager.notify(notificationId, notification) + } + + private fun NotificationBuilder.setBigText(text: CharSequence) = apply { + setStyle(NotificationCompat.BigTextStyle().bigText(text)) + } + + private fun createViewIntent(content: NotificationContent, notificationId: Int): PendingIntent { + return actionCreator.createViewMessagePendingIntent(content.messageReference, notificationId) + } + + private fun createDismissIntent(content: NotificationContent, notificationId: Int): PendingIntent { + return actionCreator.createDismissMessagePendingIntent(content.messageReference, notificationId) + } + + private fun NotificationBuilder.setDeviceActions(notificationData: SingleNotificationData) = apply { + val actions = notificationData.actions + for (action in actions) { + when (action) { + NotificationAction.Reply -> addReplyAction(notificationData) + NotificationAction.MarkAsRead -> addMarkAsReadAction(notificationData) + NotificationAction.Delete -> addDeleteAction(notificationData) + } + } + } + + private fun NotificationBuilder.addReplyAction(notificationData: SingleNotificationData) { + val icon = resourceProvider.iconReply + val title = resourceProvider.actionReply() + val content = notificationData.content + val messageReference = content.messageReference + val replyToMessagePendingIntent = + actionCreator.createReplyPendingIntent(messageReference, notificationData.notificationId) + + addAction(icon, title, replyToMessagePendingIntent) + } + + private fun NotificationBuilder.addMarkAsReadAction(notificationData: SingleNotificationData) { + val icon = resourceProvider.iconMarkAsRead + val title = resourceProvider.actionMarkAsRead() + val content = notificationData.content + val notificationId = notificationData.notificationId + val messageReference = content.messageReference + val action = actionCreator.createMarkMessageAsReadPendingIntent(messageReference, notificationId) + + addAction(icon, title, action) + } + + private fun NotificationBuilder.addDeleteAction(notificationData: SingleNotificationData) { + val icon = resourceProvider.iconDelete + val title = resourceProvider.actionDelete() + val content = notificationData.content + val notificationId = notificationData.notificationId + val messageReference = content.messageReference + val action = actionCreator.createDeleteMessagePendingIntent(messageReference, notificationId) + + addAction(icon, title, action) + } + + private fun NotificationBuilder.setWearActions(notificationData: SingleNotificationData) = apply { + val wearableExtender = WearableExtender().apply { + for (action in notificationData.wearActions) { + when (action) { + WearNotificationAction.Reply -> addReplyAction(notificationData) + WearNotificationAction.MarkAsRead -> addMarkAsReadAction(notificationData) + WearNotificationAction.Delete -> addDeleteAction(notificationData) + WearNotificationAction.Archive -> addArchiveAction(notificationData) + WearNotificationAction.Spam -> addMarkAsSpamAction(notificationData) + } + } + } + + extend(wearableExtender) + } + + private fun WearableExtender.addReplyAction(notificationData: SingleNotificationData) { + val icon = resourceProvider.wearIconReplyAll + val title = resourceProvider.actionReply() + val messageReference = notificationData.content.messageReference + val notificationId = notificationData.notificationId + val action = actionCreator.createReplyPendingIntent(messageReference, notificationId) + val replyAction = NotificationCompat.Action.Builder(icon, title, action).build() + + addAction(replyAction) + } + + private fun WearableExtender.addMarkAsReadAction(notificationData: SingleNotificationData) { + val icon = resourceProvider.wearIconMarkAsRead + val title = resourceProvider.actionMarkAsRead() + val messageReference = notificationData.content.messageReference + val notificationId = notificationData.notificationId + val action = actionCreator.createMarkMessageAsReadPendingIntent(messageReference, notificationId) + val markAsReadAction = NotificationCompat.Action.Builder(icon, title, action).build() + + addAction(markAsReadAction) + } + + private fun WearableExtender.addDeleteAction(notificationData: SingleNotificationData) { + val icon = resourceProvider.wearIconDelete + val title = resourceProvider.actionDelete() + val messageReference = notificationData.content.messageReference + val notificationId = notificationData.notificationId + val action = actionCreator.createDeleteMessagePendingIntent(messageReference, notificationId) + val deleteAction = NotificationCompat.Action.Builder(icon, title, action).build() + + addAction(deleteAction) + } + + private fun WearableExtender.addArchiveAction(notificationData: SingleNotificationData) { + val icon = resourceProvider.wearIconArchive + val title = resourceProvider.actionArchive() + val messageReference = notificationData.content.messageReference + val notificationId = notificationData.notificationId + val action = actionCreator.createArchiveMessagePendingIntent(messageReference, notificationId) + val archiveAction = NotificationCompat.Action.Builder(icon, title, action).build() + + addAction(archiveAction) + } + + private fun WearableExtender.addMarkAsSpamAction(notificationData: SingleNotificationData) { + val icon = resourceProvider.wearIconMarkAsSpam + val title = resourceProvider.actionMarkAsSpam() + val messageReference = notificationData.content.messageReference + val notificationId = notificationData.notificationId + val action = actionCreator.createMarkMessageAsSpamPendingIntent(messageReference, notificationId) + val spamAction = NotificationCompat.Action.Builder(icon, title, action).build() + + addAction(spamAction) + } + + private fun NotificationBuilder.setLockScreenNotification( + notificationData: BaseNotificationData, + addLockScreenNotification: Boolean + ) = apply { + if (addLockScreenNotification) { + lockScreenNotificationCreator.configureLockScreenNotification(this, notificationData) + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/SingleMessageNotificationDataCreator.kt b/app/core/src/main/java/com/fsck/k9/notification/SingleMessageNotificationDataCreator.kt new file mode 100644 index 0000000000000000000000000000000000000000..904e7ffcfadc6ce250accc7857c06a5d53438574 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/SingleMessageNotificationDataCreator.kt @@ -0,0 +1,89 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.K9 + +internal class SingleMessageNotificationDataCreator { + + fun createSingleNotificationData( + account: Account, + notificationId: Int, + content: NotificationContent, + timestamp: Long, + addLockScreenNotification: Boolean + ): SingleNotificationData { + return SingleNotificationData( + notificationId = notificationId, + isSilent = true, + timestamp = timestamp, + content = content, + actions = createSingleNotificationActions(), + wearActions = createSingleNotificationWearActions(account), + addLockScreenNotification = addLockScreenNotification + ) + } + + fun createSummarySingleNotificationData( + data: NotificationData, + timestamp: Long, + silent: Boolean + ): SummarySingleNotificationData { + return SummarySingleNotificationData( + SingleNotificationData( + notificationId = NotificationIds.getNewMailSummaryNotificationId(data.account), + isSilent = silent, + timestamp = timestamp, + content = data.holderForLatestNotification.content, + actions = createSingleNotificationActions(), + wearActions = createSingleNotificationWearActions(data.account), + addLockScreenNotification = false, + ), + ) + } + + @OptIn(ExperimentalStdlibApi::class) + private fun createSingleNotificationActions(): List { + return buildList { + add(NotificationAction.Reply) + add(NotificationAction.MarkAsRead) + + if (isDeleteActionEnabled()) { + add(NotificationAction.Delete) + } + } + } + + @OptIn(ExperimentalStdlibApi::class) + private fun createSingleNotificationWearActions(account: Account): List { + return buildList { + add(WearNotificationAction.Reply) + add(WearNotificationAction.MarkAsRead) + + if (isDeleteActionAvailableForWear()) { + add(WearNotificationAction.Delete) + } + + if (account.hasArchiveFolder()) { + add(WearNotificationAction.Archive) + } + + if (isSpamActionAvailableForWear(account)) { + add(WearNotificationAction.Spam) + } + } + } + + private fun isDeleteActionEnabled(): Boolean { + return K9.notificationQuickDeleteBehaviour != K9.NotificationQuickDelete.NEVER + } + + // We don't support confirming actions on Wear devices. So don't show the action when confirmation is enabled. + private fun isDeleteActionAvailableForWear(): Boolean { + return isDeleteActionEnabled() && !K9.isConfirmDeleteFromNotification + } + + // We don't support confirming actions on Wear devices. So don't show the action when confirmation is enabled. + private fun isSpamActionAvailableForWear(account: Account): Boolean { + return account.hasSpamFolder() && !K9.isConfirmSpam + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/SingleMessageNotifications.kt b/app/core/src/main/java/com/fsck/k9/notification/SingleMessageNotifications.kt deleted file mode 100644 index c4a518654287a050216e878ca269d00cc403a7a8..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/notification/SingleMessageNotifications.kt +++ /dev/null @@ -1,158 +0,0 @@ -package com.fsck.k9.notification - -import android.app.Notification -import androidx.core.app.NotificationCompat -import com.fsck.k9.Account -import com.fsck.k9.K9 - -internal open class SingleMessageNotifications( - notificationHelper: NotificationHelper, - actionCreator: NotificationActionCreator, - resourceProvider: NotificationResourceProvider -) : BaseNotifications(notificationHelper, actionCreator, resourceProvider) { - - fun buildSingleMessageNotification(account: Account, holder: NotificationHolder): Notification { - val notificationId = holder.notificationId - return createSingleMessageNotificationBuilder(account, holder, notificationId) - .setNotificationSilent() - .build() - } - - fun createSingleMessageNotificationBuilder( - account: Account, - holder: NotificationHolder, - notificationId: Int - ): NotificationCompat.Builder { - val content = holder.content - val builder = createBigTextStyleNotification(account, holder, notificationId) - - val deletePendingIntent = actionCreator.createDismissMessagePendingIntent( - context, content.messageReference, holder.notificationId - ) - builder.setDeleteIntent(deletePendingIntent) - addActions(builder, account, holder) - return builder - } - - private fun addActions(builder: NotificationCompat.Builder, account: Account, holder: NotificationHolder) { - addDeviceActions(builder, holder) - addWearActions(builder, account, holder) - } - - private fun addDeviceActions(builder: NotificationCompat.Builder, holder: NotificationHolder) { - addDeviceReplyAction(builder, holder) - addDeviceMarkAsReadAction(builder, holder) - addDeviceDeleteAction(builder, holder) - } - - private fun addDeviceReplyAction(builder: NotificationCompat.Builder, holder: NotificationHolder) { - val icon = resourceProvider.iconReply - val title = resourceProvider.actionReply() - val content = holder.content - val messageReference = content.messageReference - val replyToMessagePendingIntent = - actionCreator.createReplyPendingIntent(messageReference, holder.notificationId) - builder.addAction(icon, title, replyToMessagePendingIntent) - } - - private fun addDeviceMarkAsReadAction(builder: NotificationCompat.Builder, holder: NotificationHolder) { - val icon = resourceProvider.iconMarkAsRead - val title = resourceProvider.actionMarkAsRead() - val content = holder.content - val notificationId = holder.notificationId - val messageReference = content.messageReference - val action = actionCreator.createMarkMessageAsReadPendingIntent(messageReference, notificationId) - builder.addAction(icon, title, action) - } - - private fun addDeviceDeleteAction(builder: NotificationCompat.Builder, holder: NotificationHolder) { - if (!isDeleteActionEnabled()) { - return - } - val icon = resourceProvider.iconDelete - val title = resourceProvider.actionDelete() - val content = holder.content - val notificationId = holder.notificationId - val messageReference = content.messageReference - val action = actionCreator.createDeleteMessagePendingIntent(messageReference, notificationId) - builder.addAction(icon, title, action) - } - - private fun addWearActions(builder: NotificationCompat.Builder, account: Account, holder: NotificationHolder) { - val wearableExtender = NotificationCompat.WearableExtender() - addReplyAction(wearableExtender, holder) - addMarkAsReadAction(wearableExtender, holder) - if (isDeleteActionAvailableForWear()) { - addDeleteAction(wearableExtender, holder) - } - if (isArchiveActionAvailableForWear(account)) { - addArchiveAction(wearableExtender, holder) - } - if (isSpamActionAvailableForWear(account)) { - addMarkAsSpamAction(wearableExtender, holder) - } - builder.extend(wearableExtender) - } - - private fun addReplyAction(wearableExtender: NotificationCompat.WearableExtender, holder: NotificationHolder) { - val icon = resourceProvider.wearIconReplyAll - val title = resourceProvider.actionReply() - val messageReference = holder.content.messageReference - val notificationId = holder.notificationId - val action = actionCreator.createReplyPendingIntent(messageReference, notificationId) - val replyAction = NotificationCompat.Action.Builder(icon, title, action).build() - wearableExtender.addAction(replyAction) - } - - private fun addMarkAsReadAction(wearableExtender: NotificationCompat.WearableExtender, holder: NotificationHolder) { - val icon = resourceProvider.wearIconMarkAsRead - val title = resourceProvider.actionMarkAsRead() - val messageReference = holder.content.messageReference - val notificationId = holder.notificationId - val action = actionCreator.createMarkMessageAsReadPendingIntent(messageReference, notificationId) - val markAsReadAction = NotificationCompat.Action.Builder(icon, title, action).build() - wearableExtender.addAction(markAsReadAction) - } - - private fun addDeleteAction(wearableExtender: NotificationCompat.WearableExtender, holder: NotificationHolder) { - val icon = resourceProvider.wearIconDelete - val title = resourceProvider.actionDelete() - val messageReference = holder.content.messageReference - val notificationId = holder.notificationId - val action = actionCreator.createDeleteMessagePendingIntent(messageReference, notificationId) - val deleteAction = NotificationCompat.Action.Builder(icon, title, action).build() - wearableExtender.addAction(deleteAction) - } - - private fun addArchiveAction(wearableExtender: NotificationCompat.WearableExtender, holder: NotificationHolder) { - val icon = resourceProvider.wearIconArchive - val title = resourceProvider.actionArchive() - val messageReference = holder.content.messageReference - val notificationId = holder.notificationId - val action = actionCreator.createArchiveMessagePendingIntent(messageReference, notificationId) - val archiveAction = NotificationCompat.Action.Builder(icon, title, action).build() - wearableExtender.addAction(archiveAction) - } - - private fun addMarkAsSpamAction(wearableExtender: NotificationCompat.WearableExtender, holder: NotificationHolder) { - val icon = resourceProvider.wearIconMarkAsSpam - val title = resourceProvider.actionMarkAsSpam() - val messageReference = holder.content.messageReference - val notificationId = holder.notificationId - val action = actionCreator.createMarkMessageAsSpamPendingIntent(messageReference, notificationId) - val spamAction = NotificationCompat.Action.Builder(icon, title, action).build() - wearableExtender.addAction(spamAction) - } - - private fun isDeleteActionAvailableForWear(): Boolean { - return isDeleteActionEnabled() && !K9.isConfirmDeleteFromNotification - } - - private fun isArchiveActionAvailableForWear(account: Account): Boolean { - return account.archiveFolderId != null - } - - private fun isSpamActionAvailableForWear(account: Account): Boolean { - return account.spamFolderId != null && !K9.isConfirmSpam - } -} diff --git a/app/core/src/main/java/com/fsck/k9/notification/SummaryNotificationCreator.kt b/app/core/src/main/java/com/fsck/k9/notification/SummaryNotificationCreator.kt new file mode 100644 index 0000000000000000000000000000000000000000..d6b85a3aef2bb2e41718d2e71d16fad9fbf7a5cb --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/SummaryNotificationCreator.kt @@ -0,0 +1,210 @@ +package com.fsck.k9.notification + +import android.app.PendingIntent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.WearableExtender +import androidx.core.app.NotificationManagerCompat +import com.fsck.k9.Account +import com.fsck.k9.notification.NotificationChannelManager.ChannelType +import com.fsck.k9.notification.NotificationIds.getNewMailSummaryNotificationId +import androidx.core.app.NotificationCompat.Builder as NotificationBuilder + +internal class SummaryNotificationCreator( + private val notificationHelper: NotificationHelper, + private val actionCreator: NotificationActionCreator, + private val lockScreenNotificationCreator: LockScreenNotificationCreator, + private val singleMessageNotificationCreator: SingleMessageNotificationCreator, + private val resourceProvider: NotificationResourceProvider, + private val notificationManager: NotificationManagerCompat +) { + fun createSummaryNotification( + baseNotificationData: BaseNotificationData, + summaryNotificationData: SummaryNotificationData + ) { + when (summaryNotificationData) { + is SummarySingleNotificationData -> { + createSingleMessageNotification(baseNotificationData, summaryNotificationData.singleNotificationData) + } + is SummaryInboxNotificationData -> { + createInboxStyleSummaryNotification(baseNotificationData, summaryNotificationData) + } + } + } + + private fun createSingleMessageNotification( + baseNotificationData: BaseNotificationData, + singleNotificationData: SingleNotificationData + ) { + singleMessageNotificationCreator.createSingleNotification( + baseNotificationData, + singleNotificationData, + isGroupSummary = true + ) + } + + private fun createInboxStyleSummaryNotification( + baseNotificationData: BaseNotificationData, + notificationData: SummaryInboxNotificationData + ) { + val account = baseNotificationData.account + val accountName = baseNotificationData.accountName + val newMessagesCount = baseNotificationData.newMessagesCount + val title = resourceProvider.newMessagesTitle(newMessagesCount) + val summary = buildInboxSummaryText(accountName, notificationData) + + val notification = notificationHelper.createNotificationBuilder(account, ChannelType.MESSAGES) + .setCategory(NotificationCompat.CATEGORY_EMAIL) + .setAutoCancel(true) + .setGroup(baseNotificationData.groupKey) + .setGroupSummary(true) + .setSmallIcon(resourceProvider.iconNewMail) + .setColor(baseNotificationData.color) + .setWhen(notificationData.timestamp) + .setNumber(notificationData.additionalMessagesCount) + .setTicker(notificationData.content.firstOrNull()) + .setContentTitle(title) + .setSubText(accountName) + .setInboxStyle(title, summary, notificationData.content) + .setContentIntent(createViewIntent(account, notificationData)) + .setDeleteIntent(createDismissIntent(account, notificationData.notificationId)) + .setDeviceActions(account, notificationData) + .setWearActions(account, notificationData) + .setAppearance(notificationData.isSilent, baseNotificationData.appearance) + .setLockScreenNotification(baseNotificationData) + .build() + + notificationManager.notify(notificationData.notificationId, notification) + } + + private fun buildInboxSummaryText(accountName: String, notificationData: SummaryInboxNotificationData): String { + return if (notificationData.additionalMessagesCount > 0) { + resourceProvider.additionalMessages(notificationData.additionalMessagesCount, accountName) + } else { + accountName + } + } + + private fun NotificationBuilder.setInboxStyle( + title: String, + summary: String, + contentLines: List + ) = apply { + val style = NotificationCompat.InboxStyle() + .setBigContentTitle(title) + .setSummaryText(summary) + + for (line in contentLines) { + style.addLine(line) + } + + setStyle(style) + } + + private fun createViewIntent(account: Account, notificationData: SummaryInboxNotificationData): PendingIntent { + return actionCreator.createViewMessagesPendingIntent( + account = account, + messageReferences = notificationData.messageReferences, + notificationId = notificationData.notificationId + ) + } + + private fun createDismissIntent(account: Account, notificationId: Int): PendingIntent { + return actionCreator.createDismissAllMessagesPendingIntent(account, notificationId) + } + + private fun NotificationBuilder.setDeviceActions( + account: Account, + notificationData: SummaryInboxNotificationData + ) = apply { + for (action in notificationData.actions) { + when (action) { + SummaryNotificationAction.MarkAsRead -> addMarkAllAsReadAction(account, notificationData) + SummaryNotificationAction.Delete -> addDeleteAllAction(account, notificationData) + } + } + } + + private fun NotificationBuilder.addMarkAllAsReadAction( + account: Account, + notificationData: SummaryInboxNotificationData + ) { + val icon = resourceProvider.iconMarkAsRead + val title = resourceProvider.actionMarkAsRead() + val messageReferences = notificationData.messageReferences + val notificationId = notificationData.notificationId + val markAllAsReadPendingIntent = + actionCreator.createMarkAllAsReadPendingIntent(account, messageReferences, notificationId) + + addAction(icon, title, markAllAsReadPendingIntent) + } + + private fun NotificationBuilder.addDeleteAllAction( + account: Account, + notificationData: SummaryInboxNotificationData + ) { + val icon = resourceProvider.iconDelete + val title = resourceProvider.actionDelete() + val notificationId = getNewMailSummaryNotificationId(account) + val messageReferences = notificationData.messageReferences + val action = actionCreator.createDeleteAllPendingIntent(account, messageReferences, notificationId) + + addAction(icon, title, action) + } + + private fun NotificationBuilder.setWearActions( + account: Account, + notificationData: SummaryInboxNotificationData + ) = apply { + val wearableExtender = WearableExtender().apply { + for (action in notificationData.wearActions) { + when (action) { + SummaryWearNotificationAction.MarkAsRead -> addMarkAllAsReadAction(account, notificationData) + SummaryWearNotificationAction.Delete -> addDeleteAllAction(account, notificationData) + SummaryWearNotificationAction.Archive -> addArchiveAllAction(account, notificationData) + } + } + } + + extend(wearableExtender) + } + + private fun WearableExtender.addMarkAllAsReadAction( + account: Account, + notificationData: SummaryInboxNotificationData + ) { + val icon = resourceProvider.wearIconMarkAsRead + val title = resourceProvider.actionMarkAllAsRead() + val messageReferences = notificationData.messageReferences + val notificationId = getNewMailSummaryNotificationId(account) + val action = actionCreator.createMarkAllAsReadPendingIntent(account, messageReferences, notificationId) + val markAsReadAction = NotificationCompat.Action.Builder(icon, title, action).build() + + addAction(markAsReadAction) + } + + private fun WearableExtender.addDeleteAllAction(account: Account, notificationData: SummaryInboxNotificationData) { + val icon = resourceProvider.wearIconDelete + val title = resourceProvider.actionDeleteAll() + val messageReferences = notificationData.messageReferences + val notificationId = getNewMailSummaryNotificationId(account) + val action = actionCreator.createDeleteAllPendingIntent(account, messageReferences, notificationId) + val deleteAction = NotificationCompat.Action.Builder(icon, title, action).build() + + addAction(deleteAction) + } + + private fun WearableExtender.addArchiveAllAction(account: Account, notificationData: SummaryInboxNotificationData) { + val icon = resourceProvider.wearIconArchive + val title = resourceProvider.actionArchiveAll() + val messageReferences = notificationData.messageReferences + val notificationId = getNewMailSummaryNotificationId(account) + val action = actionCreator.createArchiveAllPendingIntent(account, messageReferences, notificationId) + val archiveAction = NotificationCompat.Action.Builder(icon, title, action).build() + + addAction(archiveAction) + } + + private fun NotificationBuilder.setLockScreenNotification(notificationData: BaseNotificationData) = apply { + lockScreenNotificationCreator.configureLockScreenNotification(this, notificationData) + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/SummaryNotificationDataCreator.kt b/app/core/src/main/java/com/fsck/k9/notification/SummaryNotificationDataCreator.kt new file mode 100644 index 0000000000000000000000000000000000000000..50d853c6b6f410e5b1e691fc4d798692a4bbf492 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/SummaryNotificationDataCreator.kt @@ -0,0 +1,85 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.K9 + +internal class SummaryNotificationDataCreator( + private val singleMessageNotificationDataCreator: SingleMessageNotificationDataCreator +) { + fun createSummaryNotificationData( + data: NotificationData, + timestamp: Long, + silent: Boolean + ): SummaryNotificationData { + val shouldBeSilent = silent || K9.isQuietTime + return if (data.isSingleMessageNotification) { + createSummarySingleNotificationData(data, timestamp, shouldBeSilent) + } else { + createSummaryInboxNotificationData(data, timestamp, shouldBeSilent) + } + } + + private fun createSummarySingleNotificationData( + data: NotificationData, + timestamp: Long, + silent: Boolean + ): SummaryNotificationData { + return singleMessageNotificationDataCreator.createSummarySingleNotificationData(data, timestamp, silent) + } + + private fun createSummaryInboxNotificationData( + data: NotificationData, + timestamp: Long, + silent: Boolean + ): SummaryNotificationData { + return SummaryInboxNotificationData( + notificationId = NotificationIds.getNewMailSummaryNotificationId(data.account), + isSilent = silent, + timestamp = timestamp, + content = getSummaryContent(data), + additionalMessagesCount = data.getSummaryOverflowMessagesCount(), + messageReferences = data.getAllMessageReferences(), + actions = createSummaryNotificationActions(), + wearActions = createSummaryWearNotificationActions(data.account) + ) + } + + private fun getSummaryContent(data: NotificationData): List { + return data.getContentForSummaryNotification().map { it.summary } + } + + @OptIn(ExperimentalStdlibApi::class) + private fun createSummaryNotificationActions(): List { + return buildList { + add(SummaryNotificationAction.MarkAsRead) + + if (isDeleteActionEnabled()) { + add(SummaryNotificationAction.Delete) + } + } + } + + @OptIn(ExperimentalStdlibApi::class) + private fun createSummaryWearNotificationActions(account: Account): List { + return buildList { + add(SummaryWearNotificationAction.MarkAsRead) + + if (isDeleteActionAvailableForWear()) { + add(SummaryWearNotificationAction.Delete) + } + + if (account.hasArchiveFolder()) { + add(SummaryWearNotificationAction.Archive) + } + } + } + + private fun isDeleteActionEnabled(): Boolean { + return K9.notificationQuickDeleteBehaviour == K9.NotificationQuickDelete.ALWAYS + } + + // We don't support confirming actions on Wear devices. So don't show the action when confirmation is enabled. + private fun isDeleteActionAvailableForWear(): Boolean { + return isDeleteActionEnabled() && !K9.isConfirmDeleteFromNotification + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/SyncNotificationController.kt b/app/core/src/main/java/com/fsck/k9/notification/SyncNotificationController.kt new file mode 100644 index 0000000000000000000000000000000000000000..f7bebe91cdd204788b2b22cf1e40f0db2f6888a5 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/SyncNotificationController.kt @@ -0,0 +1,133 @@ +package com.fsck.k9.notification + +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.fsck.k9.Account +import com.fsck.k9.mailstore.LocalFolder + +private const val NOTIFICATION_LED_WHILE_SYNCING = false + +internal class SyncNotificationController( + private val notificationHelper: NotificationHelper, + private val actionBuilder: NotificationActionCreator, + private val resourceProvider: NotificationResourceProvider +) { + fun showSendingNotification(account: Account) { + val accountName = notificationHelper.getAccountName(account) + val title = resourceProvider.sendingMailTitle() + val tickerText = resourceProvider.sendingMailBody(accountName) + + val notificationId = NotificationIds.getFetchingMailNotificationId(account) + val outboxFolderId = account.outboxFolderId ?: error("Outbox folder not configured") + val showMessageListPendingIntent = actionBuilder.createViewFolderPendingIntent( + account, outboxFolderId, notificationId + ) + + val notificationBuilder = notificationHelper + .createNotificationBuilder(account, NotificationChannelManager.ChannelType.MISCELLANEOUS) + .setSmallIcon(resourceProvider.iconSendingMail) + .setWhen(System.currentTimeMillis()) + .setOngoing(true) + .setTicker(tickerText) + .setContentTitle(title) + .setContentText(accountName) + .setContentIntent(showMessageListPendingIntent) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + + if (NOTIFICATION_LED_WHILE_SYNCING) { + notificationHelper.configureNotification( + builder = notificationBuilder, + ringtone = null, + vibrationPattern = null, + ledColor = account.notificationSetting.ledColor, + ledSpeed = NotificationHelper.NOTIFICATION_LED_BLINK_FAST, + ringAndVibrate = true + ) + } + + notificationManager.notify(notificationId, notificationBuilder.build()) + } + + fun clearSendingNotification(account: Account) { + val notificationId = NotificationIds.getFetchingMailNotificationId(account) + notificationManager.cancel(notificationId) + } + + fun showFetchingMailNotification(account: Account, folder: LocalFolder) { + val accountName = account.description + val folderId = folder.databaseId + val folderName = folder.name + val tickerText = resourceProvider.checkingMailTicker(accountName, folderName) + val title = resourceProvider.checkingMailTitle() + + // TODO: Use format string from resources + val text = accountName + resourceProvider.checkingMailSeparator() + folderName + + val notificationId = NotificationIds.getFetchingMailNotificationId(account) + val showMessageListPendingIntent = actionBuilder.createViewFolderPendingIntent( + account, folderId, notificationId + ) + + val notificationBuilder = notificationHelper + .createNotificationBuilder(account, NotificationChannelManager.ChannelType.MISCELLANEOUS) + .setSmallIcon(resourceProvider.iconCheckingMail) + .setWhen(System.currentTimeMillis()) + .setOngoing(true) + .setTicker(tickerText) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(showMessageListPendingIntent) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + + if (NOTIFICATION_LED_WHILE_SYNCING) { + notificationHelper.configureNotification( + builder = notificationBuilder, + ringtone = null, + vibrationPattern = null, + ledColor = account.notificationSetting.ledColor, + ledSpeed = NotificationHelper.NOTIFICATION_LED_BLINK_FAST, + ringAndVibrate = true + ) + } + + notificationManager.notify(notificationId, notificationBuilder.build()) + } + + fun showEmptyFetchingMailNotification(account: Account) { + val title = resourceProvider.checkingMailTitle() + val text = account.description + val notificationId = NotificationIds.getFetchingMailNotificationId(account) + + val notificationBuilder = notificationHelper + .createNotificationBuilder(account, NotificationChannelManager.ChannelType.MISCELLANEOUS) + .setSmallIcon(resourceProvider.iconCheckingMail) + .setWhen(System.currentTimeMillis()) + .setOngoing(true) + .setContentTitle(title) + .setContentText(text) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + + if (NOTIFICATION_LED_WHILE_SYNCING) { + notificationHelper.configureNotification( + builder = notificationBuilder, + ringtone = null, + vibrationPattern = null, + ledColor = account.notificationSetting.ledColor, + ledSpeed = NotificationHelper.NOTIFICATION_LED_BLINK_FAST, + ringAndVibrate = true + ) + } + + notificationManager.notify(notificationId, notificationBuilder.build()) + } + + fun clearFetchingMailNotification(account: Account) { + val notificationId = NotificationIds.getFetchingMailNotificationId(account) + notificationManager.cancel(notificationId) + } + + private val notificationManager: NotificationManagerCompat + get() = notificationHelper.getNotificationManager() +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/WearNotifications.kt b/app/core/src/main/java/com/fsck/k9/notification/WearNotifications.kt deleted file mode 100644 index b6df3fe506c0d61c3066963770ce63159a874de1..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/notification/WearNotifications.kt +++ /dev/null @@ -1,232 +0,0 @@ -package com.fsck.k9.notification - -import android.app.Notification -import androidx.core.app.NotificationCompat -import com.fsck.k9.Account -import com.fsck.k9.K9 -import com.fsck.k9.controller.MessagingController -import com.fsck.k9.notification.NotificationIds.getNewMailSummaryNotificationId - -internal open class WearNotifications( - notificationHelper: NotificationHelper, - actionCreator: NotificationActionCreator, - resourceProvider: NotificationResourceProvider -) : BaseNotifications(notificationHelper, actionCreator, resourceProvider) { - fun buildStackedNotification(account: Account, holder: NotificationHolder): Notification { - val notificationId = holder.notificationId - val content = holder.content - val builder = createBigTextStyleNotification(account, holder, notificationId) - builder.setNotificationSilent() - - val deletePendingIntent = actionCreator.createDismissMessagePendingIntent( - context, content.messageReference, holder.notificationId - ) - builder.setDeleteIntent(deletePendingIntent) - - addActions(builder, account, holder) - - return builder.build() - } - - fun addSummaryActions(builder: NotificationCompat.Builder, notificationData: NotificationData) { - val wearableExtender = NotificationCompat.WearableExtender() - - addMarkAllAsReadAction(wearableExtender, notificationData) - if (isDeleteActionAvailableForWear()) { - addDeleteAllAction(wearableExtender, notificationData) - } - if (isArchiveActionAvailableForWear(notificationData.account)) { - addArchiveAllAction(wearableExtender, notificationData) - } - builder.extend(wearableExtender) - } - - private fun addMarkAllAsReadAction( - wearableExtender: NotificationCompat.WearableExtender, - notificationData: NotificationData - ) { - val icon = resourceProvider.wearIconMarkAsRead - val title = resourceProvider.actionMarkAllAsRead() - val account = notificationData.account - val messageReferences = notificationData.getAllMessageReferences() - val notificationId = getNewMailSummaryNotificationId(account) - val action = actionCreator.createMarkAllAsReadPendingIntent(account, messageReferences, notificationId) - val markAsReadAction = NotificationCompat.Action.Builder(icon, title, action).build() - wearableExtender.addAction(markAsReadAction) - } - - private fun addDeleteAllAction( - wearableExtender: NotificationCompat.WearableExtender, - notificationData: NotificationData - ) { - val icon = resourceProvider.wearIconDelete - val title = resourceProvider.actionDeleteAll() - val account = notificationData.account - val messageReferences = notificationData.getAllMessageReferences() - val notificationId = getNewMailSummaryNotificationId(account) - val action = actionCreator.createDeleteAllPendingIntent(account, messageReferences, notificationId) - val deleteAction = NotificationCompat.Action.Builder(icon, title, action).build() - - wearableExtender.addAction(deleteAction); - wearableExtender.addAction(deleteAction) - } - - private fun addArchiveAllAction( - wearableExtender: NotificationCompat.WearableExtender, - notificationData: NotificationData - ) { - val icon = resourceProvider.wearIconArchive - val title = resourceProvider.actionArchiveAll() - val account = notificationData.account - val messageReferences = notificationData.getAllMessageReferences() - val notificationId = getNewMailSummaryNotificationId(account) - val action = actionCreator.createArchiveAllPendingIntent(account, messageReferences, notificationId) - val archiveAction = NotificationCompat.Action.Builder(icon, title, action).build() - - wearableExtender.addAction(archiveAction) - } - - private fun addActions(builder: NotificationCompat.Builder, account: Account, holder: NotificationHolder) { - addDeviceActions(builder, holder) - addWearActions(builder, account, holder) - } - - private fun addDeviceActions(builder: NotificationCompat.Builder, holder: NotificationHolder) { - addDeviceReplyAction(builder, holder) - addDeviceMarkAsReadAction(builder, holder) - addDeviceDeleteAction(builder, holder) - } - - private fun addDeviceReplyAction(builder: NotificationCompat.Builder, holder: NotificationHolder) { - val icon = resourceProvider.iconReply - val title = resourceProvider.actionReply() - val content = holder.content - val messageReference = content.messageReference - val replyToMessagePendingIntent = - actionCreator.createReplyPendingIntent(messageReference, holder.notificationId) - - builder.addAction(icon, title, replyToMessagePendingIntent) - } - - private fun addDeviceMarkAsReadAction(builder: NotificationCompat.Builder, holder: NotificationHolder) { - val icon = resourceProvider.iconMarkAsRead - val title = resourceProvider.actionMarkAsRead() - val content = holder.content - val notificationId = holder.notificationId - val messageReference = content.messageReference - val action = actionCreator.createMarkMessageAsReadPendingIntent(messageReference, notificationId) - builder.addAction(icon, title, action) - } - - private fun addDeviceDeleteAction(builder: NotificationCompat.Builder, holder: NotificationHolder) { - if (!isDeleteActionEnabled()) { - return - } - - val icon = resourceProvider.iconDelete - val title = resourceProvider.actionDelete() - val content = holder.content - val notificationId = holder.notificationId - val messageReference = content.messageReference - val action = actionCreator.createDeleteMessagePendingIntent(messageReference, notificationId) - builder.addAction(icon, title, action) - } - - private fun addWearActions(builder: NotificationCompat.Builder, account: Account, holder: NotificationHolder) { - val wearableExtender = NotificationCompat.WearableExtender() - - addReplyAction(wearableExtender, holder) - addMarkAsReadAction(wearableExtender, holder) - if (isDeleteActionAvailableForWear()) { - addDeleteAction(wearableExtender, holder) - } - - if (isArchiveActionAvailableForWear(account)) { - addArchiveAction(wearableExtender, holder) - } - - if (isSpamActionAvailableForWear(account)) { - addMarkAsSpamAction(wearableExtender, holder) - } - - builder.extend(wearableExtender) - } - - private fun addReplyAction(wearableExtender: NotificationCompat.WearableExtender, holder: NotificationHolder) { - val icon = resourceProvider.wearIconReplyAll - val title = resourceProvider.actionReply() - val messageReference = holder.content.messageReference - val notificationId = holder.notificationId - val action = actionCreator.createReplyPendingIntent(messageReference, notificationId) - val replyAction = NotificationCompat.Action.Builder(icon, title, action).build() - wearableExtender.addAction(replyAction) - } - - private fun addMarkAsReadAction(wearableExtender: NotificationCompat.WearableExtender, holder: NotificationHolder) { - val icon = resourceProvider.wearIconMarkAsRead - val title = resourceProvider.actionMarkAsRead() - val messageReference = holder.content.messageReference - val notificationId = holder.notificationId - val action = actionCreator.createMarkMessageAsReadPendingIntent(messageReference, notificationId) - val markAsReadAction = NotificationCompat.Action.Builder(icon, title, action).build() - - wearableExtender.addAction(markAsReadAction) - } - - private fun addDeleteAction(wearableExtender: NotificationCompat.WearableExtender, holder: NotificationHolder) { - val icon = resourceProvider.wearIconDelete - val title = resourceProvider.actionDelete() - val messageReference = holder.content.messageReference - val notificationId = holder.notificationId - val action = actionCreator.createDeleteMessagePendingIntent(messageReference, notificationId) - val deleteAction = NotificationCompat.Action.Builder(icon, title, action).build() - - wearableExtender.addAction(deleteAction) - } - - private fun addArchiveAction(wearableExtender: NotificationCompat.WearableExtender, holder: NotificationHolder) { - val icon = resourceProvider.wearIconArchive - val title = resourceProvider.actionArchive() - val messageReference = holder.content.messageReference - val notificationId = holder.notificationId - val action = actionCreator.createArchiveMessagePendingIntent(messageReference, notificationId) - val archiveAction = NotificationCompat.Action.Builder(icon, title, action).build() - - wearableExtender.addAction(archiveAction) - } - - private fun addMarkAsSpamAction(wearableExtender: NotificationCompat.WearableExtender, holder: NotificationHolder) { - val icon = resourceProvider.wearIconMarkAsSpam - val title = resourceProvider.actionMarkAsSpam() - val messageReference = holder.content.messageReference - val notificationId = holder.notificationId - val action = actionCreator.createMarkMessageAsSpamPendingIntent(messageReference, notificationId) - val spamAction = NotificationCompat.Action.Builder(icon, title, action).build() - - wearableExtender.addAction(spamAction) - } - - private fun isDeleteActionAvailableForWear(): Boolean { - return isDeleteActionEnabled() && !K9.isConfirmDeleteFromNotification - } - - private fun isArchiveActionAvailableForWear(account: Account): Boolean { - return isMovePossible(account, account.archiveFolderId) - } - - private fun isSpamActionAvailableForWear(account: Account): Boolean { - return !K9.isConfirmSpam && isMovePossible(account, account.spamFolderId) - } - - private fun isMovePossible(account: Account, destinationFolderId: Long?): Boolean { - if (destinationFolderId == null) { - return false - } - val controller = createMessagingController() - return controller.isMoveCapable(account) - } - - protected open fun createMessagingController(): MessagingController { - return MessagingController.getInstance(context) - } -} \ No newline at end of file diff --git a/app/core/src/main/java/com/fsck/k9/power/AndroidPowerManager.kt b/app/core/src/main/java/com/fsck/k9/power/AndroidPowerManager.kt new file mode 100644 index 0000000000000000000000000000000000000000..fbf30c048d2b0b3ce931e008eeabe8158e8ab951 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/power/AndroidPowerManager.kt @@ -0,0 +1,90 @@ +package com.fsck.k9.power + +import android.annotation.SuppressLint +import android.os.SystemClock +import com.fsck.k9.mail.power.PowerManager +import com.fsck.k9.mail.power.WakeLock +import java.util.concurrent.atomic.AtomicInteger +import timber.log.Timber +import android.os.PowerManager as SystemPowerManager +import android.os.PowerManager.WakeLock as SystemWakeLock + +internal class AndroidPowerManager(private val systemPowerManager: SystemPowerManager) : PowerManager { + override fun newWakeLock(tag: String): WakeLock { + return AndroidWakeLock(SystemPowerManager.PARTIAL_WAKE_LOCK, tag) + } + + inner class AndroidWakeLock(flags: Int, val tag: String?) : WakeLock { + private val wakeLock: SystemWakeLock = systemPowerManager.newWakeLock(flags, tag) + private val id = wakeLockId.getAndIncrement() + + @Volatile + private var startTime: Long? = null + + @Volatile + private var timeout: Long? = null + + init { + Timber.v("AndroidWakeLock for tag %s / id %d: Create", tag, id) + } + + override fun acquire(timeout: Long) { + synchronized(wakeLock) { + wakeLock.acquire(timeout) + } + + Timber.v("AndroidWakeLock for tag %s / id %d for %d ms: acquired", tag, id, timeout) + + if (startTime == null) { + startTime = SystemClock.elapsedRealtime() + } + + this.timeout = timeout + } + + @SuppressLint("WakelockTimeout") + override fun acquire() { + synchronized(wakeLock) { + wakeLock.acquire() + } + + Timber.v("AndroidWakeLock for tag %s / id %d: acquired with no timeout.", tag, id) + + if (startTime == null) { + startTime = SystemClock.elapsedRealtime() + } + + timeout = null + } + + override fun setReferenceCounted(counted: Boolean) { + synchronized(wakeLock) { + wakeLock.setReferenceCounted(counted) + } + } + + override fun release() { + val startTime = this.startTime + if (startTime != null) { + val endTime = SystemClock.elapsedRealtime() + + Timber.v( + "AndroidWakeLock for tag %s / id %d: releasing after %d ms, timeout = %d ms", + tag, id, endTime - startTime, timeout + ) + } else { + Timber.v("AndroidWakeLock for tag %s / id %d, timeout = %d ms: releasing", tag, id, timeout) + } + + synchronized(wakeLock) { + wakeLock.release() + } + + this.startTime = null + } + } + + companion object { + private val wakeLockId = AtomicInteger(0) + } +} diff --git a/app/core/src/main/java/com/fsck/k9/power/KoinModule.kt b/app/core/src/main/java/com/fsck/k9/power/KoinModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..e5e01228bcf618fb703902f54c03f2fb17a22008 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/power/KoinModule.kt @@ -0,0 +1,10 @@ +package com.fsck.k9.power + +import android.content.Context +import com.fsck.k9.mail.power.PowerManager +import org.koin.dsl.module + +val powerModule = module { + factory { get().getSystemService(Context.POWER_SERVICE) as android.os.PowerManager } + single { AndroidPowerManager(systemPowerManager = get()) } +} diff --git a/app/core/src/main/java/com/fsck/k9/preferences/AccountSettingsDescriptions.java b/app/core/src/main/java/com/fsck/k9/preferences/AccountSettingsDescriptions.java index f5b371d9ce0f9ad55d02e4194124fc835a548c4e..6e5101d6b365d5c4e1713fcbf2772b38114c57f5 100644 --- a/app/core/src/main/java/com/fsck/k9/preferences/AccountSettingsDescriptions.java +++ b/app/core/src/main/java/com/fsck/k9/preferences/AccountSettingsDescriptions.java @@ -413,8 +413,8 @@ public class AccountSettingsDescriptions { @Override public String fromString(String value) { StorageManager storageManager = StorageManager.getInstance(context); - Map providers = storageManager.getAvailableProviders(); - if (providers.containsKey(value)) { + Set providers = storageManager.getAvailableProviders(); + if (providers.contains(value)) { return value; } throw new RuntimeException("Validation failed"); diff --git a/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsDescriptions.java b/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsDescriptions.java index a31e9917897099ae6f65fb4b7216995bd919c5cb..e7b7f920a7894307453e9c16fb9c9db22a5aa625 100644 --- a/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsDescriptions.java +++ b/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsDescriptions.java @@ -36,7 +36,7 @@ import com.fsck.k9.preferences.Settings.WebFontSizeSetting; import static com.fsck.k9.K9.LockScreenNotificationVisibility; -public class GeneralSettingsDescriptions { +public class GeneralSettingsDescriptions { static final Map> SETTINGS; private static final Map UPGRADERS; diff --git a/app/core/src/main/java/com/fsck/k9/preferences/ServerTypeConverter.kt b/app/core/src/main/java/com/fsck/k9/preferences/ServerTypeConverter.kt index 7f2c77eb3f234db2fdd86445242993b15ccba6d3..9d64def8b3dbe7be3f969c19572faa67accc43d6 100644 --- a/app/core/src/main/java/com/fsck/k9/preferences/ServerTypeConverter.kt +++ b/app/core/src/main/java/com/fsck/k9/preferences/ServerTypeConverter.kt @@ -1,7 +1,5 @@ package com.fsck.k9.preferences -import java.util.Locale - object ServerTypeConverter { @JvmStatic fun toServerSettingsType(exportType: String): String = exportType.lowercase() diff --git a/app/core/src/main/java/com/fsck/k9/preferences/SettingsExporter.kt b/app/core/src/main/java/com/fsck/k9/preferences/SettingsExporter.kt index 35db0adecfde78b63a9c88b1a4437ffc07556589..f8f09c264b80160021ee69684fabb34861d211dc 100644 --- a/app/core/src/main/java/com/fsck/k9/preferences/SettingsExporter.kt +++ b/app/core/src/main/java/com/fsck/k9/preferences/SettingsExporter.kt @@ -210,7 +210,6 @@ class SettingsExporter( } } - val folderRepository = folderRepositoryManager.getFolderRepository(account) writeFolderNameSettings(account, folderRepository, serializer) serializer.endTag(null, SETTINGS_ELEMENT) diff --git a/app/core/src/main/java/com/fsck/k9/provider/RawMessageProvider.java b/app/core/src/main/java/com/fsck/k9/provider/RawMessageProvider.java index 1f80af69d61c7317b2ab3afe9b611bee86a5a2ac..37fcfddeb1a9da163ff2edcfc33569231bc6e7fd 100644 --- a/app/core/src/main/java/com/fsck/k9/provider/RawMessageProvider.java +++ b/app/core/src/main/java/com/fsck/k9/provider/RawMessageProvider.java @@ -99,8 +99,7 @@ public class RawMessageProvider extends ContentProvider { private long computeMessageSize(LocalMessage message) { // TODO: Store message size in database when saving message so this can be a simple lookup instead. - try { - CountingOutputStream countingOutputStream = new CountingOutputStream(); + try (CountingOutputStream countingOutputStream = new CountingOutputStream()) { message.writeTo(countingOutputStream); return countingOutputStream.getCount(); } catch (IOException | MessagingException e) { diff --git a/app/core/src/main/java/com/fsck/k9/service/DatabaseUpgradeService.java b/app/core/src/main/java/com/fsck/k9/service/DatabaseUpgradeService.java index a630594561586bb1fc8966b8d72c7d0012498e2b..790be15076dabf219e8afbbcdf326e8150887662 100644 --- a/app/core/src/main/java/com/fsck/k9/service/DatabaseUpgradeService.java +++ b/app/core/src/main/java/com/fsck/k9/service/DatabaseUpgradeService.java @@ -8,13 +8,14 @@ import android.app.Service; import android.content.Context; import android.content.Intent; import android.os.IBinder; -import android.os.PowerManager; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import com.fsck.k9.Account; import com.fsck.k9.DI; import com.fsck.k9.K9; import com.fsck.k9.Preferences; +import com.fsck.k9.mail.power.PowerManager; +import com.fsck.k9.mail.power.WakeLock; import com.fsck.k9.mailstore.LocalStoreProvider; import com.fsck.k9.power.TracingPowerManager; import com.fsck.k9.power.TracingPowerManager.TracingWakeLock; @@ -102,7 +103,7 @@ public class DatabaseUpgradeService extends Service { private int mProgress; private int mProgressEnd; - private TracingWakeLock mWakeLock; + private WakeLock mWakeLock; @Override @@ -139,8 +140,8 @@ public class DatabaseUpgradeService extends Service { * Acquire a partial wake lock so the CPU won't go to sleep when the screen is turned off. */ private void acquireWakelock() { - TracingPowerManager pm = TracingPowerManager.getPowerManager(this); - mWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKELOCK_TAG); + PowerManager pm = DI.get(PowerManager.class); + mWakeLock = pm.newWakeLock(WAKELOCK_TAG); mWakeLock.setReferenceCounted(false); mWakeLock.acquire(WAKELOCK_TIMEOUT); } diff --git a/app/core/src/test/java/com/fsck/k9/QuietTimeCheckerTest.kt b/app/core/src/test/java/com/fsck/k9/QuietTimeCheckerTest.kt index f1ca422f2d5e0bbe20c312d60fde199e09f2a860..19c67aced53a62d8751f454dc1f8fdf56f284978 100644 --- a/app/core/src/test/java/com/fsck/k9/QuietTimeCheckerTest.kt +++ b/app/core/src/test/java/com/fsck/k9/QuietTimeCheckerTest.kt @@ -4,11 +4,9 @@ import java.util.Calendar import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -import org.mockito.Mockito.mock -import org.mockito.kotlin.whenever class QuietTimeCheckerTest { - private val clock = mock(Clock::class.java) + private val clock = TestClock() @Test fun endTimeBeforeStartTime_timeIsBeforeEndOfQuietTime() { @@ -113,7 +111,6 @@ class QuietTimeCheckerTest { calendar.set(Calendar.HOUR_OF_DAY, hourOfDay) calendar.set(Calendar.MINUTE, minute) - val timeInMillis = calendar.timeInMillis - whenever(clock.time).thenReturn(timeInMillis) + clock.time = calendar.timeInMillis } } diff --git a/app/core/src/test/java/com/fsck/k9/TestCoreResourceProvider.kt b/app/core/src/test/java/com/fsck/k9/TestCoreResourceProvider.kt index d3052bf46448bca22653ae3badefe6f4b4650e0b..36e47248d865305ccc8263b358f5df133e3e533d 100644 --- a/app/core/src/test/java/com/fsck/k9/TestCoreResourceProvider.kt +++ b/app/core/src/test/java/com/fsck/k9/TestCoreResourceProvider.kt @@ -7,14 +7,6 @@ class TestCoreResourceProvider : CoreResourceProvider { override fun defaultIdentityDescription() = "initial identity" - override fun internalStorageProviderName(): String { - throw UnsupportedOperationException("not implemented") - } - - override fun externalStorageProviderName(): String { - throw UnsupportedOperationException("not implemented") - } - override fun contactDisplayNamePrefix() = "To:" override fun contactUnknownSender() = "" override fun contactUnknownRecipient() = "" diff --git a/app/core/src/test/java/com/fsck/k9/controller/MessageReferenceTest.java b/app/core/src/test/java/com/fsck/k9/controller/MessageReferenceTest.java deleted file mode 100644 index 97f5166a2b4f152e749339c377eebc727c9d0cf2..0000000000000000000000000000000000000000 --- a/app/core/src/test/java/com/fsck/k9/controller/MessageReferenceTest.java +++ /dev/null @@ -1,209 +0,0 @@ -package com.fsck.k9.controller; - - -import com.fsck.k9.mail.Flag; -import org.junit.Test; - -import static junit.framework.Assert.assertEquals; -import static junit.framework.Assert.assertFalse; -import static junit.framework.Assert.assertNull; -import static junit.framework.Assert.assertTrue; -import static org.junit.Assert.assertNotNull; - - -public class MessageReferenceTest { - - @Test - public void checkIdentityStringFromMessageReferenceWithoutFlag() { - MessageReference messageReference = createMessageReference("o hai!", 2, "10101010"); - - assertEquals("#:byBoYWkh:Mg==:MTAxMDEwMTA=", messageReference.toIdentityString()); - } - - @Test - public void checkIdentityStringFromMessageReferenceWithFlag() { - MessageReference messageReference = createMessageReferenceWithFlag("o hai!", 2, "10101010", Flag.ANSWERED); - - assertEquals("#:byBoYWkh:Mg==:MTAxMDEwMTA=:ANSWERED", messageReference.toIdentityString()); - } - - @Test - public void parseIdentityStringWithoutFlag() { - MessageReference messageReference = MessageReference.parse("#:byBoYWkh:Mg==:MTAxMDEwMTA="); - - assertNotNull(messageReference); - assertEquals("o hai!", messageReference.getAccountUuid()); - assertEquals(2, messageReference.getFolderId()); - assertEquals("10101010", messageReference.getUid()); - assertNull(messageReference.getFlag()); - } - - @Test - public void parseIdentityStringWithFlag() { - MessageReference messageReference = MessageReference.parse("#:byBoYWkh:Mg==:MTAxMDEwMTA=:ANSWERED"); - - assertNotNull(messageReference); - assertEquals("o hai!", messageReference.getAccountUuid()); - assertEquals(2, messageReference.getFolderId()); - assertEquals("10101010", messageReference.getUid()); - assertEquals(Flag.ANSWERED, messageReference.getFlag()); - } - - @Test - public void checkMessageReferenceWithChangedUid() { - MessageReference messageReferenceOne = createMessageReferenceWithFlag("account", 1, "uid", Flag.SEEN); - - MessageReference messageReferenceTwo = messageReferenceOne.withModifiedUid("---"); - - assertEquals("account", messageReferenceTwo.getAccountUuid()); - assertEquals(1, messageReferenceTwo.getFolderId()); - assertEquals("---", messageReferenceTwo.getUid()); - assertEquals(Flag.SEEN, messageReferenceTwo.getFlag()); - } - - @Test - public void checkMessageReferenceWithChangedFlag() { - MessageReference messageReferenceOne = createMessageReferenceWithFlag("account", 1, "uid", Flag.ANSWERED); - - MessageReference messageReferenceTwo = messageReferenceOne.withModifiedFlag(Flag.DELETED); - - assertEquals("account", messageReferenceTwo.getAccountUuid()); - assertEquals(1, messageReferenceTwo.getFolderId()); - assertEquals("uid", messageReferenceTwo.getUid()); - assertEquals(Flag.DELETED, messageReferenceTwo.getFlag()); - } - - @Test - public void parseIdentityStringContainingBadVersionNumber() { - MessageReference messageReference = MessageReference.parse("@:byBoYWkh:MTAxMDEwMTA=:ANSWERED"); - - assertNull(messageReference); - } - - @SuppressWarnings("ConstantConditions") - @Test - public void parseNullIdentityString() { - MessageReference messageReference = MessageReference.parse(null); - - assertNull(messageReference); - } - - @Test - public void parseIdentityStringWithCorruptFlag() { - MessageReference messageReference = - MessageReference.parse("!:%^&%^*$&$by&(BYWkh:Zm9%^@sZGVy:MT-35#$AxMDEwMTA=:ANSWE!RED"); - - assertNull(messageReference); - } - - @Test - public void equalsWithAnObjectShouldReturnFalse() { - MessageReference messageReference = new MessageReference("a", 1, "c", null); - Object object = new Object(); - - assertFalse(messageReference.equals(object)); - } - - @SuppressWarnings({"ObjectEqualsNull", "ConstantConditions"}) - @Test - public void equalsWithNullShouldReturnFalse() { - MessageReference messageReference = createMessageReference("account", 1, "uid"); - - assertFalse(messageReference.equals(null)); - } - - @SuppressWarnings("EqualsWithItself") - @Test - public void equalsWithSameMessageReferenceShouldReturnTrue() { - MessageReference messageReference = createMessageReference("account", 1, "uid"); - - assertTrue(messageReference.equals(messageReference)); - } - - @Test - public void equalsWithMessageReferenceContainingSameDataShouldReturnTrue() { - MessageReference messageReferenceOne = createMessageReference("account", 1, "uid"); - MessageReference messageReferenceTwo = createMessageReference("account", 1, "uid"); - - assertEqualsReturnsTrueSymmetrically(messageReferenceOne, messageReferenceTwo); - } - - @Test - public void equalsWithMessageReferenceContainingDifferentAccountUuidShouldReturnFalse() { - MessageReference messageReferenceOne = createMessageReference("account", 1, "uid"); - MessageReference messageReferenceTwo = createMessageReference("-------", 1, "uid"); - - assertEqualsReturnsFalseSymmetrically(messageReferenceOne, messageReferenceTwo); - } - - @Test - public void equalsWithMessageReferenceContainingDifferentFolderNameShouldReturnFalse() { - MessageReference messageReferenceOne = createMessageReference("account", 1, "uid"); - MessageReference messageReferenceTwo = createMessageReference("account", 8, "uid"); - - assertEqualsReturnsFalseSymmetrically(messageReferenceOne, messageReferenceTwo); - } - - @Test - public void equalsWithMessageReferenceContainingDifferentUidShouldReturnFalse() { - MessageReference messageReferenceOne = createMessageReference("account", 1, "uid"); - MessageReference messageReferenceTwo = createMessageReference("account", 1, "---"); - - assertEqualsReturnsFalseSymmetrically(messageReferenceOne, messageReferenceTwo); - } - - @Test - public void alternativeEquals() { - MessageReference messageReference = createMessageReference("account", 1, "uid"); - - boolean equalsResult = messageReference.equals("account", 1, "uid"); - - assertTrue(equalsResult); - } - - @Test - public void equals_withNullAccount_shouldReturnFalse() { - MessageReference messageReference = createMessageReference("account", 1, "uid"); - - boolean equalsResult = messageReference.equals(null, 1, "uid"); - - assertFalse(equalsResult); - } - - @Test - public void equals_withNullUid_shouldReturnFalse() { - MessageReference messageReference = createMessageReference("account", 1, "uid"); - - boolean equalsResult = messageReference.equals("account", 1, null); - - assertFalse(equalsResult); - } - - @Test(expected = NullPointerException.class) - public void constructor_withNullAccount_shouldThrow() { - createMessageReference(null, 1, "uid"); - } - - @Test(expected = NullPointerException.class) - public void constructor_withNullUid_shouldThrow() { - createMessageReference("account", 1, null); - } - - private MessageReference createMessageReference(String accountUuid, long folderId, String uid) { - return new MessageReference(accountUuid, folderId, uid, null); - } - - private MessageReference createMessageReferenceWithFlag(String accountUuid, long folderId, String uid, Flag flag) { - return new MessageReference(accountUuid, folderId, uid, flag); - } - - private void assertEqualsReturnsTrueSymmetrically(MessageReference referenceOne, MessageReference referenceTwo) { - assertTrue(referenceOne.equals(referenceTwo)); - assertTrue(referenceTwo.equals(referenceOne)); - } - - private void assertEqualsReturnsFalseSymmetrically(MessageReference referenceOne, MessageReference referenceTwo) { - assertFalse(referenceOne.equals(referenceTwo)); - assertFalse(referenceTwo.equals(referenceOne)); - } -} diff --git a/app/core/src/test/java/com/fsck/k9/controller/MessageReferenceTest.kt b/app/core/src/test/java/com/fsck/k9/controller/MessageReferenceTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..495f053c19eccce707cc5e82bb5e1ef919bc8561 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/controller/MessageReferenceTest.kt @@ -0,0 +1,61 @@ +package com.fsck.k9.controller + +import com.google.common.truth.Truth.assertThat +import kotlin.test.assertNotNull +import org.junit.Test + +class MessageReferenceTest { + @Test + fun checkIdentityStringFromMessageReference() { + val messageReference = MessageReference("o hai!", 2, "10101010") + + val serialized = messageReference.toIdentityString() + + assertThat(serialized).isEqualTo("#:byBoYWkh:Mg==:MTAxMDEwMTA=") + } + + @Test + fun parseIdentityString() { + val result = MessageReference.parse("#:byBoYWkh:Mg==:MTAxMDEwMTA=") + + assertNotNull(result) { messageReference -> + assertThat(messageReference.accountUuid).isEqualTo("o hai!") + assertThat(messageReference.folderId).isEqualTo(2) + assertThat(messageReference.uid).isEqualTo("10101010") + } + } + + @Test + fun parseIdentityStringContainingBadVersionNumber() { + val messageReference = MessageReference.parse("@:byBoYWkh:MTAxMDEwMTA=") + + assertThat(messageReference).isNull() + } + + @Test + fun parseNullIdentityString() { + val messageReference = MessageReference.parse(null) + + assertThat(messageReference).isNull() + } + + @Test + fun checkMessageReferenceWithChangedUid() { + val messageReferenceOne = MessageReference("account", 1, "uid") + + val messageReference = messageReferenceOne.withModifiedUid("---") + + assertThat(messageReference.accountUuid).isEqualTo("account") + assertThat(messageReference.folderId).isEqualTo(1) + assertThat(messageReference.uid).isEqualTo("---") + } + + @Test + fun alternativeEquals() { + val messageReference = MessageReference("account", 1, "uid") + + val equalsResult = messageReference.equals("account", 1, "uid") + + assertThat(equalsResult).isTrue() + } +} 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 e810f1d9db821e4b7018845cf7c18131784b5065..e07dfc540ae516960e4296ae551a10e2c9e68b8a 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 @@ -53,14 +53,14 @@ import org.robolectric.shadows.ShadowLog; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.nullable; -import static org.mockito.Matchers.eq; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; @@ -140,6 +140,7 @@ public class MessagingControllerTest extends K9RobolectricTest { controller = new MessagingController(appContext, notificationController, notificationStrategy, localStoreProvider, messageCountsProvider, backendManager, preferences, messageStoreManager, saveMessageDataCreator, Collections.emptyList()); + configureAccount(); configureBackendManager(); configureLocalStore(); @@ -166,7 +167,6 @@ public class MessagingControllerTest extends K9RobolectricTest { verify(localFolder).clearAllMessages(); } - @Test public void refreshRemoteSynchronous_shouldCallBackend() throws MessagingException { controller.refreshFolderListSynchronous(account); @@ -219,11 +219,11 @@ public class MessagingControllerTest extends K9RobolectricTest { new Answer() { @Override public LocalMessage answer(InvocationOnMock invocation) throws Throwable { - if (hasFetchedMessage) { + if(hasFetchedMessage) { return localNewMessage2; - } else { - return null; } + else + return null; } } ); @@ -330,7 +330,7 @@ public class MessagingControllerTest extends K9RobolectricTest { controller.sendPendingMessagesSynchronous(account); - verifyZeroInteractions(listener); + verifyNoMoreInteractions(listener); } @Test @@ -373,8 +373,7 @@ public class MessagingControllerTest extends K9RobolectricTest { } @Test - public void sendPendingMessagesSynchronous_whenMessageSentSuccesfully_shouldUpdateProgress() - throws MessagingException { + public void sendPendingMessagesSynchronous_whenMessageSentSuccesfully_shouldUpdateProgress() throws MessagingException { setupAccountWithMessageToSend(); controller.sendPendingMessagesSynchronous(account); @@ -420,7 +419,7 @@ public class MessagingControllerTest extends K9RobolectricTest { when(localFolder.getMessages(null)).thenReturn(Collections.singletonList(localMessageToSend1)); when(localMessageToSend1.getUid()).thenReturn("localMessageToSend1"); when(localMessageToSend1.getDatabaseId()).thenReturn(42L); - when(localMessageToSend1.getHeader(K9.IDENTITY_HEADER)).thenReturn(new String[] {}); + when(localMessageToSend1.getHeader(K9.IDENTITY_HEADER)).thenReturn(new String[]{}); OutboxState outboxState = new OutboxState(SendState.READY, 0, null, 0); OutboxStateRepository outboxStateRepository = mock(OutboxStateRepository.class); @@ -442,7 +441,6 @@ public class MessagingControllerTest extends K9RobolectricTest { ConnectionSecurity.SSL_TLS_REQUIRED, AuthType.PLAIN, "username", "password", null)); account.setOutgoingServerSettings(new ServerSettings(Protocols.SMTP, "host", 465, ConnectionSecurity.SSL_TLS_REQUIRED, AuthType.PLAIN, "username", "password", null)); - account.setMaximumAutoDownloadMessageSize(MAXIMUM_SMALL_MESSAGE_SIZE); account.setEmail("user@host.com"); } diff --git a/app/core/src/test/java/com/fsck/k9/helper/ReplyToParserTest.java b/app/core/src/test/java/com/fsck/k9/helper/ReplyToParserTest.java index d612a7413ab2f3c60db27a8e5e2505aaa75b565c..501b2731b965eb759a871ff1604915109a80719d 100644 --- a/app/core/src/test/java/com/fsck/k9/helper/ReplyToParserTest.java +++ b/app/core/src/test/java/com/fsck/k9/helper/ReplyToParserTest.java @@ -17,8 +17,8 @@ import org.junit.Test; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; -import static org.mockito.Matchers.any; -import static org.mockito.Matchers.eq; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; diff --git a/app/core/src/test/java/com/fsck/k9/logging/LogcatLogFileWriterTest.kt b/app/core/src/test/java/com/fsck/k9/logging/LogcatLogFileWriterTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..2fcc6e93ff9ebc590d804dd821a2a79ef2a1b473 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/logging/LogcatLogFileWriterTest.kt @@ -0,0 +1,85 @@ +package com.fsck.k9.logging + +import android.content.ContentResolver +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.FileNotFoundException +import java.io.IOException +import java.io.InputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +class LogcatLogFileWriterTest { + private val contentUri = mock() + private val outputStream = ByteArrayOutputStream() + + @Test + fun `write log to contentUri`() = runBlocking { + val logData = "a".repeat(10_000) + val logFileWriter = LogcatLogFileWriter( + contentResolver = createContentResolver(), + processExecutor = createProcessExecutor(logData), + coroutineDispatcher = Dispatchers.Unconfined + ) + + logFileWriter.writeLogTo(contentUri) + + assertThat(outputStream.toByteArray().decodeToString()).isEqualTo(logData) + } + + @Test(expected = FileNotFoundException::class) + fun `contentResolver throws`() = runBlocking { + val logFileWriter = LogcatLogFileWriter( + contentResolver = createThrowingContentResolver(FileNotFoundException()), + processExecutor = createProcessExecutor("irrelevant"), + coroutineDispatcher = Dispatchers.Unconfined + ) + + logFileWriter.writeLogTo(contentUri) + } + + @Test(expected = IOException::class) + fun `processExecutor throws`() = runBlocking { + val logFileWriter = LogcatLogFileWriter( + contentResolver = createContentResolver(), + processExecutor = ThrowingProcessExecutor(IOException()), + coroutineDispatcher = Dispatchers.Unconfined + ) + + logFileWriter.writeLogTo(contentUri) + } + + private fun createContentResolver(): ContentResolver { + return mock { + on { openOutputStream(contentUri) } doReturn outputStream + } + } + + private fun createThrowingContentResolver(exception: Exception): ContentResolver { + return mock { + on { openOutputStream(contentUri) } doAnswer { throw exception } + } + } + + private fun createProcessExecutor(logData: String): DataProcessExecutor { + return DataProcessExecutor(logData.toByteArray(charset = Charsets.US_ASCII)) + } +} + +private class DataProcessExecutor(val data: ByteArray) : ProcessExecutor { + override fun exec(command: String): InputStream { + return ByteArrayInputStream(data) + } +} + +private class ThrowingProcessExecutor(val exception: Exception) : ProcessExecutor { + override fun exec(command: String): InputStream { + throw exception + } +} diff --git a/app/core/src/test/java/com/fsck/k9/mailstore/MessageViewInfoExtractorTest.java b/app/core/src/test/java/com/fsck/k9/mailstore/MessageViewInfoExtractorTest.java index a8b128547d8702331fc5fbd8c4637adadae2967d..563286c8e552601eee6ffb27e46cb7c93c6f08cd 100644 --- a/app/core/src/test/java/com/fsck/k9/mailstore/MessageViewInfoExtractorTest.java +++ b/app/core/src/test/java/com/fsck/k9/mailstore/MessageViewInfoExtractorTest.java @@ -50,7 +50,7 @@ import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.assertNull; import static junit.framework.Assert.assertSame; import static junit.framework.Assert.assertTrue; -import static org.mockito.Matchers.anyString; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; diff --git a/app/core/src/test/java/com/fsck/k9/message/IdentityHeaderBuilderTest.kt b/app/core/src/test/java/com/fsck/k9/message/IdentityHeaderBuilderTest.kt index 6cefafcdee154f26302d6b5f3c0ec2ededd6157f..2be6075663d483c2f0daaf0a17703bfd3f99f70f 100644 --- a/app/core/src/test/java/com/fsck/k9/message/IdentityHeaderBuilderTest.kt +++ b/app/core/src/test/java/com/fsck/k9/message/IdentityHeaderBuilderTest.kt @@ -53,4 +53,4 @@ class IdentityHeaderBuilderTest : RobolectricTest() { ): Identity { return Identity(description, name, email, signature, signatureUse, replyTo) } -} \ No newline at end of file +} diff --git a/app/core/src/test/java/com/fsck/k9/message/MessageBuilderTest.java b/app/core/src/test/java/com/fsck/k9/message/MessageBuilderTest.java index f94c6fa39d21a13812523e422876298fa870ce28..267e448b838175f89a82242ecbc3b221fb5b901d 100644 --- a/app/core/src/test/java/com/fsck/k9/message/MessageBuilderTest.java +++ b/app/core/src/test/java/com/fsck/k9/message/MessageBuilderTest.java @@ -38,8 +38,8 @@ import org.robolectric.annotation.LooperMode; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; -import static org.mockito.Matchers.any; -import static org.mockito.Matchers.eq; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; diff --git a/app/core/src/test/java/com/fsck/k9/message/html/HtmlConverterTest.java b/app/core/src/test/java/com/fsck/k9/message/html/HtmlConverterTest.java index 8dfbbaacdea71b8e48566e33fa78509b5f2d7d18..d7c34f9fb7308985a15fe6559d430baaaebda237 100644 --- a/app/core/src/test/java/com/fsck/k9/message/html/HtmlConverterTest.java +++ b/app/core/src/test/java/com/fsck/k9/message/html/HtmlConverterTest.java @@ -306,4 +306,24 @@ public class HtmlConverterTest { assertEquals("https://domain.example/path/", result); } + + @Test + public void htmlToText_withLineBreaksInHtml() { + String input = "One\nTwo\r\nThree"; + + String result = HtmlConverter.htmlToText(input); + + assertEquals("One Two Three", result); + } + + @Test + public void htmlToText_withLongTextLine_shouldNotAddLineBreaksToOutput() { + String input = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam sit amet finibus felis, " + + "viverra ullamcorper justo. Suspendisse potenti. Etiam erat sem, interdum a condimentum quis, " + + "fringilla quis orci."; + + String result = HtmlConverter.htmlToText(input); + + assertEquals(input, result); + } } diff --git a/app/core/src/test/java/com/fsck/k9/notification/AddNotificationResultTest.kt b/app/core/src/test/java/com/fsck/k9/notification/AddNotificationResultTest.kt index 50a1b3fd1c1f2dbd1c7ca31230367bb41f30588d..e6597aca41fcd3af8a87926d130c46969510b12e 100644 --- a/app/core/src/test/java/com/fsck/k9/notification/AddNotificationResultTest.kt +++ b/app/core/src/test/java/com/fsck/k9/notification/AddNotificationResultTest.kt @@ -10,12 +10,11 @@ class AddNotificationResultTest { private val notificationHolder = NotificationHolder( notificationId = NOTIFICATION_ID, content = NotificationContent( - messageReference = MessageReference("irrelevant", 1, "irrelevant", null), + messageReference = MessageReference("irrelevant", 1, "irrelevant"), sender = "irrelevant", subject = "irrelevant", preview = "irrelevant", - summary = "irrelevant", - isStarred = false + summary = "irrelevant" ) ) @@ -48,7 +47,6 @@ class AddNotificationResultTest { } @Test - fun getNotificationHolder_shouldReturnNotificationHolder() { val result = AddNotificationResult.replaceNotification(notificationHolder) diff --git a/app/core/src/test/java/com/fsck/k9/notification/AuthenticationErrorNotificationControllerTest.kt b/app/core/src/test/java/com/fsck/k9/notification/AuthenticationErrorNotificationControllerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..f53b9fae77d77f6e004c1d1c272b29a3de77df59 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/AuthenticationErrorNotificationControllerTest.kt @@ -0,0 +1,110 @@ +package com.fsck.k9.notification + +import android.app.Notification +import android.app.PendingIntent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.test.core.app.ApplicationProvider +import com.fsck.k9.Account +import com.fsck.k9.RobolectricTest +import com.fsck.k9.testing.MockHelper.mockBuilder +import org.junit.Test +import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +private const val INCOMING = true +private const val OUTGOING = false +private const val ACCOUNT_NUMBER = 1 +private const val ACCOUNT_NAME = "TestAccount" + +class AuthenticationErrorNotificationControllerTest : RobolectricTest() { + private val resourceProvider = TestNotificationResourceProvider() + private val notification = mock() + private val notificationManager = mock() + private val builder = createFakeNotificationBuilder(notification) + private val notificationHelper = createFakeNotificationHelper(notificationManager, builder) + private val account = createFakeAccount() + private val controller = TestAuthenticationErrorNotificationController() + private val contentIntent = mock() + + @Test + fun showAuthenticationErrorNotification_withIncomingServer_shouldCreateNotification() { + val notificationId = NotificationIds.getAuthenticationErrorNotificationId(account, INCOMING) + + controller.showAuthenticationErrorNotification(account, INCOMING) + + verify(notificationManager).notify(notificationId, notification) + assertAuthenticationErrorNotificationContents() + } + + @Test + fun clearAuthenticationErrorNotification_withIncomingServer_shouldCancelNotification() { + val notificationId = NotificationIds.getAuthenticationErrorNotificationId(account, INCOMING) + + controller.clearAuthenticationErrorNotification(account, INCOMING) + + verify(notificationManager).cancel(notificationId) + } + + @Test + fun showAuthenticationErrorNotification_withOutgoingServer_shouldCreateNotification() { + val notificationId = NotificationIds.getAuthenticationErrorNotificationId(account, OUTGOING) + + controller.showAuthenticationErrorNotification(account, OUTGOING) + + verify(notificationManager).notify(notificationId, notification) + assertAuthenticationErrorNotificationContents() + } + + @Test + fun clearAuthenticationErrorNotification_withOutgoingServer_shouldCancelNotification() { + val notificationId = NotificationIds.getAuthenticationErrorNotificationId(account, OUTGOING) + + controller.clearAuthenticationErrorNotification(account, OUTGOING) + + verify(notificationManager).cancel(notificationId) + } + + private fun assertAuthenticationErrorNotificationContents() { + verify(builder).setSmallIcon(resourceProvider.iconWarning) + verify(builder).setTicker("Authentication failed") + verify(builder).setContentTitle("Authentication failed") + verify(builder).setContentText("Authentication failed for $ACCOUNT_NAME. Update your server settings.") + verify(builder).setContentIntent(contentIntent) + verify(builder).setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + } + + private fun createFakeNotificationBuilder(notification: Notification): NotificationCompat.Builder { + return mockBuilder { + on { build() } doReturn notification + } + } + + private fun createFakeNotificationHelper( + notificationManager: NotificationManagerCompat, + builder: NotificationCompat.Builder + ): NotificationHelper { + return mock { + on { getContext() } doReturn ApplicationProvider.getApplicationContext() + on { getNotificationManager() } doReturn notificationManager + on { createNotificationBuilder(any(), any()) } doReturn builder + } + } + + private fun createFakeAccount(): Account { + return mock { + on { accountNumber } doReturn ACCOUNT_NUMBER + on { description } doReturn ACCOUNT_NAME + } + } + + internal inner class TestAuthenticationErrorNotificationController : + AuthenticationErrorNotificationController(notificationHelper, mock(), resourceProvider) { + + override fun createContentIntent(account: Account, incoming: Boolean): PendingIntent { + return contentIntent + } + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/BaseNotificationDataCreatorTest.kt b/app/core/src/test/java/com/fsck/k9/notification/BaseNotificationDataCreatorTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..772dc2b96daa483244d6f4bebf1534b244dc1c4e --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/BaseNotificationDataCreatorTest.kt @@ -0,0 +1,197 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.Identity +import com.fsck.k9.K9 +import com.fsck.k9.K9.LockScreenNotificationVisibility +import com.fsck.k9.NotificationSetting +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.mockito.kotlin.mock + +class BaseNotificationDataCreatorTest { + private val account = createAccount() + private val notificationDataCreator = BaseNotificationDataCreator() + + @Test + fun `account instance`() { + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.account).isSameInstanceAs(account) + } + + @Test + fun `account name from description property`() { + account.description = "description" + account.email = "irrelevant@k9mail.example" + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.accountName).isEqualTo("description") + } + + @Test + fun `account description is blank`() { + account.description = "" + account.email = "test@k9mail.example" + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.accountName).isEqualTo("test@k9mail.example") + } + + @Test + fun `account description is null`() { + account.description = null + account.email = "test@k9mail.example" + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.accountName).isEqualTo("test@k9mail.example") + } + + @Test + fun `group key`() { + account.accountNumber = 42 + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.groupKey).isEqualTo("newMailNotifications-42") + } + + @Test + fun `notification color`() { + account.chipColor = 0xFF0000 + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.color).isEqualTo(0xFF0000) + } + + @Test + fun `new messages count`() { + val notificationData = createNotificationData(senders = listOf("irrelevant", "irrelevant")) + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.newMessagesCount).isEqualTo(2) + } + + @Test + fun `do not display notification on lock screen`() { + setLockScreenMode(LockScreenNotificationVisibility.NOTHING) + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.lockScreenNotificationData).isEqualTo(LockScreenNotificationData.None) + } + + @Test + fun `display application name on lock screen`() { + setLockScreenMode(LockScreenNotificationVisibility.APP_NAME) + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.lockScreenNotificationData).isEqualTo(LockScreenNotificationData.AppName) + } + + @Test + fun `display new message count on lock screen`() { + setLockScreenMode(LockScreenNotificationVisibility.MESSAGE_COUNT) + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.lockScreenNotificationData).isEqualTo(LockScreenNotificationData.MessageCount) + } + + @Test + fun `display message sender names on lock screen`() { + setLockScreenMode(LockScreenNotificationVisibility.SENDERS) + val notificationData = createNotificationData(senders = listOf("Sender One", "Sender Two", "Sender Three")) + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.lockScreenNotificationData).isInstanceOf(LockScreenNotificationData.SenderNames::class.java) + val senderNamesData = result.lockScreenNotificationData as LockScreenNotificationData.SenderNames + assertThat(senderNamesData.senderNames).isEqualTo("Sender Three, Sender Two, Sender One") + } + + @Test + fun `display notification on lock screen`() { + setLockScreenMode(LockScreenNotificationVisibility.EVERYTHING) + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.lockScreenNotificationData).isEqualTo(LockScreenNotificationData.Public) + } + + @Test + fun ringtone() { + account.notificationSetting.ringtone = "content://ringtone/1" + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.appearance.ringtone).isEqualTo("content://ringtone/1") + } + + @Test + fun `vibration pattern`() { + account.notificationSetting.vibratePattern = 3 + account.notificationSetting.vibrateTimes = 2 + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.appearance.vibrationPattern).isEqualTo(NotificationSetting.getVibration(3, 2)) + } + + @Test + fun `led color`() { + account.notificationSetting.ledColor = 0x00FF00 + val notificationData = createNotificationData() + + val result = notificationDataCreator.createBaseNotificationData(notificationData) + + assertThat(result.appearance.ledColor).isEqualTo(0x00FF00) + } + + private fun setLockScreenMode(mode: LockScreenNotificationVisibility) { + K9.lockScreenNotificationVisibility = mode + } + + private fun createNotificationData(senders: List = emptyList()): NotificationData { + val notificationData = NotificationData(account) + for (sender in senders) { + notificationData.addNotificationContent( + NotificationContent( + messageReference = mock(), + sender = sender, + preview = "irrelevant", + summary = "irrelevant", + subject = "irrelevant" + ) + ) + } + return notificationData + } + + private fun createAccount(): Account { + return Account("00000000-0000-4000-0000-000000000000").apply { + description = "account name" + identities = listOf(Identity()) + notificationSetting.vibrateTimes = 1 + } + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/CertificateErrorNotificationControllerTest.kt b/app/core/src/test/java/com/fsck/k9/notification/CertificateErrorNotificationControllerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..ea55fd8044cf572903a213354808632c3dc35986 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/CertificateErrorNotificationControllerTest.kt @@ -0,0 +1,111 @@ +package com.fsck.k9.notification + +import android.app.Notification +import android.app.PendingIntent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.test.core.app.ApplicationProvider +import com.fsck.k9.Account +import com.fsck.k9.RobolectricTest +import com.fsck.k9.testing.MockHelper.mockBuilder +import org.junit.Test +import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +private const val INCOMING = true +private const val OUTGOING = false +private const val ACCOUNT_NUMBER = 1 +private const val ACCOUNT_NAME = "TestAccount" + +class CertificateErrorNotificationControllerTest : RobolectricTest() { + private val resourceProvider: NotificationResourceProvider = TestNotificationResourceProvider() + private val notification = mock() + private val notificationManager = mock() + private val builder = createFakeNotificationBuilder(notification) + private val notificationHelper = createFakeNotificationHelper(notificationManager, builder) + private val account = createFakeAccount() + private val controller = TestCertificateErrorNotificationController() + private val contentIntent = mock() + + @Test + fun testShowCertificateErrorNotificationForIncomingServer() { + val notificationId = NotificationIds.getCertificateErrorNotificationId(account, INCOMING) + + controller.showCertificateErrorNotification(account, INCOMING) + + verify(notificationManager).notify(notificationId, notification) + assertCertificateErrorNotificationContents() + } + + @Test + fun testClearCertificateErrorNotificationsForIncomingServer() { + val notificationId = NotificationIds.getCertificateErrorNotificationId(account, INCOMING) + + controller.clearCertificateErrorNotifications(account, INCOMING) + + verify(notificationManager).cancel(notificationId) + } + + @Test + fun testShowCertificateErrorNotificationForOutgoingServer() { + val notificationId = NotificationIds.getCertificateErrorNotificationId(account, OUTGOING) + + controller.showCertificateErrorNotification(account, OUTGOING) + + verify(notificationManager).notify(notificationId, notification) + assertCertificateErrorNotificationContents() + } + + @Test + fun testClearCertificateErrorNotificationsForOutgoingServer() { + val notificationId = NotificationIds.getCertificateErrorNotificationId(account, OUTGOING) + + controller.clearCertificateErrorNotifications(account, OUTGOING) + + verify(notificationManager).cancel(notificationId) + } + + private fun assertCertificateErrorNotificationContents() { + verify(builder).setSmallIcon(resourceProvider.iconWarning) + verify(builder).setTicker("Certificate error for $ACCOUNT_NAME") + verify(builder).setContentTitle("Certificate error for $ACCOUNT_NAME") + verify(builder).setContentText("Check your server settings") + verify(builder).setContentIntent(contentIntent) + verify(builder).setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + } + + private fun createFakeNotificationBuilder(notification: Notification): NotificationCompat.Builder { + return mockBuilder { + on { build() } doReturn notification + } + } + + private fun createFakeNotificationHelper( + notificationManager: NotificationManagerCompat, + builder: NotificationCompat.Builder + ): NotificationHelper { + return mock { + on { getContext() } doReturn ApplicationProvider.getApplicationContext() + on { getNotificationManager() } doReturn notificationManager + on { createNotificationBuilder(any(), any()) } doReturn builder + } + } + + private fun createFakeAccount(): Account { + return mock { + on { accountNumber } doReturn ACCOUNT_NUMBER + on { description } doReturn ACCOUNT_NAME + on { uuid } doReturn "test-uuid" + } + } + + internal inner class TestCertificateErrorNotificationController : CertificateErrorNotificationController( + notificationHelper, mock(), resourceProvider + ) { + override fun createContentIntent(account: Account, incoming: Boolean): PendingIntent { + return contentIntent + } + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/LockScreenNotificationCreatorTest.kt b/app/core/src/test/java/com/fsck/k9/notification/LockScreenNotificationCreatorTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..f92ec2a8662769c88ba96d5725eca65c37fec990 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/LockScreenNotificationCreatorTest.kt @@ -0,0 +1,116 @@ +package com.fsck.k9.notification + +import androidx.core.app.NotificationCompat +import androidx.test.core.app.ApplicationProvider +import com.fsck.k9.Account +import com.fsck.k9.RobolectricTest +import com.fsck.k9.testing.MockHelper.mockBuilder +import org.junit.Test +import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +class LockScreenNotificationCreatorTest : RobolectricTest() { + private val account = Account("00000000-0000-0000-0000-000000000000") + private val resourceProvider = TestNotificationResourceProvider() + private val builder = createFakeNotificationBuilder() + private val publicBuilder = createFakeNotificationBuilder() + private var notificationCreator = LockScreenNotificationCreator( + notificationHelper = createFakeNotificationHelper(publicBuilder), + resourceProvider = resourceProvider + ) + + @Test + fun `no lock screen notification`() { + val baseNotificationData = createBaseNotificationData(LockScreenNotificationData.None) + + notificationCreator.configureLockScreenNotification(builder, baseNotificationData) + + verify(builder).setVisibility(NotificationCompat.VISIBILITY_SECRET) + } + + @Test + fun `app name`() { + val baseNotificationData = createBaseNotificationData(LockScreenNotificationData.AppName) + + notificationCreator.configureLockScreenNotification(builder, baseNotificationData) + + verify(builder).setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + } + + @Test + fun `regular notification on lock screen`() { + val baseNotificationData = createBaseNotificationData(LockScreenNotificationData.Public) + + notificationCreator.configureLockScreenNotification(builder, baseNotificationData) + + verify(builder).setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + } + + @Test + fun `list of sender names`() { + val baseNotificationData = createBaseNotificationData( + lockScreenNotificationData = LockScreenNotificationData.SenderNames("Alice, Bob"), + newMessagesCount = 2 + ) + + notificationCreator.configureLockScreenNotification(builder, baseNotificationData) + + verify(publicBuilder).setSmallIcon(resourceProvider.iconNewMail) + verify(publicBuilder).setNumber(2) + verify(publicBuilder).setContentTitle("2 new messages") + verify(publicBuilder).setContentText("Alice, Bob") + verify(builder).setPublicVersion(publicBuilder.build()) + } + + @Test + fun `new message count`() { + val baseNotificationData = createBaseNotificationData( + lockScreenNotificationData = LockScreenNotificationData.MessageCount, + accountName = "Account name", + newMessagesCount = 23 + ) + + notificationCreator.configureLockScreenNotification(builder, baseNotificationData) + + verify(publicBuilder).setSmallIcon(resourceProvider.iconNewMail) + verify(publicBuilder).setNumber(23) + verify(publicBuilder).setContentTitle("23 new messages") + verify(publicBuilder).setContentText("Account name") + verify(builder).setPublicVersion(publicBuilder.build()) + } + + private fun createFakeNotificationBuilder(): NotificationCompat.Builder { + return mockBuilder { + on { build() } doReturn mock() + } + } + + private fun createFakeNotificationHelper(builder: NotificationCompat.Builder): NotificationHelper { + return mock { + on { getContext() } doReturn ApplicationProvider.getApplicationContext() + on { createNotificationBuilder(any(), any()) } doReturn builder + } + } + + private fun createBaseNotificationData( + lockScreenNotificationData: LockScreenNotificationData, + accountName: String = "irrelevant", + newMessagesCount: Int = 0 + ): BaseNotificationData { + return BaseNotificationData( + account = account, + accountName = accountName, + groupKey = "irrelevant", + color = 0, + newMessagesCount = newMessagesCount, + lockScreenNotificationData = lockScreenNotificationData, + appearance = NotificationAppearance( + ringtone = null, + vibrationPattern = longArrayOf(), + ledColor = 0 + ) + ) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/NewMailNotificationManagerTest.kt b/app/core/src/test/java/com/fsck/k9/notification/NewMailNotificationManagerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..085043eaaf278d450bc6fe4a6b2e441b11be74fa --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/NewMailNotificationManagerTest.kt @@ -0,0 +1,291 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.TestClock +import com.fsck.k9.controller.MessageReference +import com.fsck.k9.mailstore.LocalMessage +import com.google.common.truth.Truth.assertThat +import kotlin.test.assertNotNull +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.stubbing + +private const val ACCOUNT_UUID = "00000000-0000-4000-0000-000000000000" +private const val ACCOUNT_NAME = "Personal" +private const val ACCOUNT_COLOR = 0xFF112233L.toInt() +private const val FOLDER_ID = 42L +private const val TIMESTAMP = 23L + +class NewMailNotificationManagerTest { + private val account = createAccount() + private val notificationContentCreator = mock() + private val clock = TestClock(TIMESTAMP) + private val manager = NewMailNotificationManager( + notificationContentCreator, + BaseNotificationDataCreator(), + SingleMessageNotificationDataCreator(), + SummaryNotificationDataCreator(SingleMessageNotificationDataCreator()), + clock + ) + + @Test + fun `add first notification`() { + val message = addMessageToNotificationContentCreator( + sender = "sender", + subject = "subject", + preview = "preview", + summary = "summary", + messageUid = "msg-1" + ) + + val result = manager.addNewMailNotification(account, message, silent = false) + + assertThat(result.singleNotificationData.first().content).isEqualTo( + NotificationContent( + messageReference = createMessageReference("msg-1"), + sender = "sender", + subject = "subject", + preview = "preview", + summary = "summary" + ) + ) + assertThat(result.summaryNotificationData).isInstanceOf(SummarySingleNotificationData::class.java) + val summaryNotificationData = result.summaryNotificationData as SummarySingleNotificationData + assertThat(summaryNotificationData.singleNotificationData.isSilent).isFalse() + } + + @Test + fun `add second notification`() { + val messageOne = addMessageToNotificationContentCreator( + sender = "Alice", + subject = "Hi Bob", + preview = "How are you?", + summary = "Alice Hi Bob", + messageUid = "msg-1" + ) + val messageTwo = addMessageToNotificationContentCreator( + sender = "Zoe", + subject = "Meeting", + preview = "We need to talk", + summary = "Zoe Meeting", + messageUid = "msg-2" + ) + manager.addNewMailNotification(account, messageOne, silent = false) + val timestamp = TIMESTAMP + 1000 + clock.time = timestamp + + val result = manager.addNewMailNotification(account, messageTwo, silent = false) + + assertThat(result.singleNotificationData.first().content).isEqualTo( + NotificationContent( + messageReference = createMessageReference("msg-2"), + sender = "Zoe", + subject = "Meeting", + preview = "We need to talk", + summary = "Zoe Meeting" + ) + ) + assertThat(result.baseNotificationData.newMessagesCount).isEqualTo(2) + assertThat(result.summaryNotificationData).isInstanceOf(SummaryInboxNotificationData::class.java) + val summaryNotificationData = result.summaryNotificationData as SummaryInboxNotificationData + assertThat(summaryNotificationData.content).isEqualTo(listOf("Zoe Meeting", "Alice Hi Bob")) + assertThat(summaryNotificationData.messageReferences).isEqualTo( + listOf( + createMessageReference("msg-2"), + createMessageReference("msg-1") + ) + ) + assertThat(summaryNotificationData.additionalMessagesCount).isEqualTo(0) + assertThat(summaryNotificationData.isSilent).isFalse() + } + + @Test + fun `add one more notification when already displaying the maximum number of notifications`() { + addMaximumNumberOfNotifications() + val message = addMessageToNotificationContentCreator( + sender = "Alice", + subject = "Another one", + preview = "Are you tired of me yet?", + summary = "Alice Another one", + messageUid = "msg-x" + ) + + val result = manager.addNewMailNotification(account, message, silent = false) + + val notificationId = NotificationIds.getSingleMessageNotificationId(account, index = 0) + assertThat(result.cancelNotificationIds).isEqualTo(listOf(notificationId)) + assertThat(result.singleNotificationData.first().notificationId).isEqualTo(notificationId) + } + + @Test + fun `remove notification when none was added before should return null`() { + val result = manager.removeNewMailNotification(account, createMessageReference("any")) + + assertThat(result).isNull() + } + + @Test + fun `remove notification with untracked notification ID should return null`() { + val message = addMessageToNotificationContentCreator( + sender = "Alice", + subject = "Another one", + preview = "Are you tired of me yet?", + summary = "Alice Another one", + messageUid = "msg-x" + ) + manager.addNewMailNotification(account, message, silent = false) + + val result = manager.removeNewMailNotification(account, createMessageReference("untracked")) + + assertThat(result).isNull() + } + + @Test + fun `remove last remaining notification`() { + val message = addMessageToNotificationContentCreator( + sender = "Alice", + subject = "Hello", + preview = "How are you?", + summary = "Alice Hello", + messageUid = "msg-1" + ) + manager.addNewMailNotification(account, message, silent = false) + + val result = manager.removeNewMailNotification(account, createMessageReference("msg-1")) + + assertNotNull(result) { data -> + assertThat(data.cancelNotificationIds).containsExactly( + NotificationIds.getNewMailSummaryNotificationId(account), + NotificationIds.getSingleMessageNotificationId(account, 0) + ) + assertThat(data.singleNotificationData).isEmpty() + assertThat(data.summaryNotificationData).isNull() + } + } + + @Test + fun `remove one of three notifications`() { + val messageOne = addMessageToNotificationContentCreator( + sender = "Alice", + subject = "One", + preview = "preview", + summary = "Alice One", + messageUid = "msg-1" + ) + manager.addNewMailNotification(account, messageOne, silent = false) + val messageTwo = addMessageToNotificationContentCreator( + sender = "Alice", + subject = "Two", + preview = "preview", + summary = "Alice Two", + messageUid = "msg-2" + ) + val dataTwo = manager.addNewMailNotification(account, messageTwo, silent = true) + val notificationIdTwo = dataTwo.singleNotificationData.first().notificationId + val messageThree = addMessageToNotificationContentCreator( + sender = "Alice", + subject = "Three", + preview = "preview", + summary = "Alice Three", + messageUid = "msg-3" + ) + manager.addNewMailNotification(account, messageThree, silent = true) + + val result = manager.removeNewMailNotification(account, createMessageReference("msg-2")) + + assertNotNull(result) { data -> + assertThat(data.cancelNotificationIds).isEqualTo(listOf(notificationIdTwo)) + assertThat(data.singleNotificationData).isEmpty() + assertThat(data.baseNotificationData.newMessagesCount).isEqualTo(2) + assertThat(data.summaryNotificationData).isInstanceOf(SummaryInboxNotificationData::class.java) + val summaryNotificationData = data.summaryNotificationData as SummaryInboxNotificationData + assertThat(summaryNotificationData.content).isEqualTo(listOf("Alice Three", "Alice One")) + assertThat(summaryNotificationData.messageReferences).isEqualTo( + listOf( + createMessageReference("msg-3"), + createMessageReference("msg-1") + ) + ) + } + } + + @Test + fun `remove notification when additional notifications are available`() { + val message = addMessageToNotificationContentCreator( + sender = "Alice", + subject = "Another one", + preview = "Are you tired of me yet?", + summary = "Alice Another one", + messageUid = "msg-restore" + ) + manager.addNewMailNotification(account, message, silent = false) + addMaximumNumberOfNotifications() + + val result = manager.removeNewMailNotification(account, createMessageReference("msg-1")) + + assertNotNull(result) { data -> + assertThat(data.cancelNotificationIds).hasSize(1) + assertThat(data.baseNotificationData.newMessagesCount) + .isEqualTo(NotificationData.MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS) + + val singleNotificationData = data.singleNotificationData.first() + assertThat(singleNotificationData.notificationId).isEqualTo(data.cancelNotificationIds.first()) + assertThat(singleNotificationData.isSilent).isTrue() + assertThat(singleNotificationData.content).isEqualTo( + NotificationContent( + messageReference = createMessageReference("msg-restore"), + sender = "Alice", + subject = "Another one", + preview = "Are you tired of me yet?", + summary = "Alice Another one" + ) + ) + } + } + + private fun createAccount(): Account { + return Account(ACCOUNT_UUID).apply { + description = ACCOUNT_NAME + chipColor = ACCOUNT_COLOR + notificationSetting.vibrateTimes = 1 + } + } + + private fun addMaximumNumberOfNotifications() { + repeat(NotificationData.MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS) { index -> + val message = addMessageToNotificationContentCreator( + sender = "sender", + subject = "subject", + preview = "preview", + summary = "summary", + messageUid = "msg-$index" + ) + manager.addNewMailNotification(account, message, silent = true) + } + } + + private fun addMessageToNotificationContentCreator( + sender: String, + subject: String, + preview: String, + summary: String, + messageUid: String + ): LocalMessage { + val message = mock() + + stubbing(notificationContentCreator) { + on { createFromMessage(account, message) } doReturn + NotificationContent( + messageReference = createMessageReference(messageUid), + sender, subject, preview, summary + ) + } + + return message + } + + private fun createMessageReference(messageUid: String): MessageReference { + return MessageReference(ACCOUNT_UUID, FOLDER_ID, messageUid) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/NotificationContentCreatorTest.kt b/app/core/src/test/java/com/fsck/k9/notification/NotificationContentCreatorTest.kt index 17a5aceebb2e10aa7b41c9681fe027c9069943dd..c706443137b72cee6e002e7857d4b386697c7ebc 100644 --- a/app/core/src/test/java/com/fsck/k9/notification/NotificationContentCreatorTest.kt +++ b/app/core/src/test/java/com/fsck/k9/notification/NotificationContentCreatorTest.kt @@ -5,7 +5,6 @@ import com.fsck.k9.Account import com.fsck.k9.RobolectricTest import com.fsck.k9.controller.MessageReference import com.fsck.k9.mail.Address -import com.fsck.k9.mail.Flag import com.fsck.k9.mail.Message.RecipientType import com.fsck.k9.mailstore.LocalMessage import com.fsck.k9.message.extractors.PreviewResult.PreviewType @@ -42,7 +41,6 @@ class NotificationContentCreatorTest : RobolectricTest() { assertThat(content.subject).isEqualTo(SUBJECT) assertThat(content.preview.toString()).isEqualTo("$SUBJECT\n$PREVIEW") assertThat(content.summary.toString()).isEqualTo("$SENDER_NAME $SUBJECT") - assertThat(content.isStarred).isFalse() } @Test @@ -50,7 +48,9 @@ class NotificationContentCreatorTest : RobolectricTest() { stubbing(message) { on { subject } doReturn null } + val content = contentCreator.createFromMessage(account, message) + assertThat(content.subject).isEqualTo("(No subject)") assertThat(content.preview.toString()).isEqualTo(PREVIEW) assertThat(content.summary.toString()).isEqualTo("$SENDER_NAME (No subject)") @@ -62,7 +62,9 @@ class NotificationContentCreatorTest : RobolectricTest() { on { previewType } doReturn PreviewType.NONE on { preview } doReturn null } + val content = contentCreator.createFromMessage(account, message) + assertThat(content.subject).isEqualTo(SUBJECT) assertThat(content.preview.toString()).isEqualTo(SUBJECT) } @@ -73,7 +75,9 @@ class NotificationContentCreatorTest : RobolectricTest() { on { previewType } doReturn PreviewType.ERROR on { preview } doReturn null } + val content = contentCreator.createFromMessage(account, message) + assertThat(content.subject).isEqualTo(SUBJECT) assertThat(content.preview.toString()).isEqualTo(SUBJECT) } @@ -84,7 +88,9 @@ class NotificationContentCreatorTest : RobolectricTest() { on { previewType } doReturn PreviewType.ENCRYPTED on { preview } doReturn null } + val content = contentCreator.createFromMessage(account, message) + assertThat(content.subject).isEqualTo(SUBJECT) assertThat(content.preview.toString()).isEqualTo("$SUBJECT\n*Encrypted*") } @@ -94,7 +100,9 @@ class NotificationContentCreatorTest : RobolectricTest() { stubbing(message) { on { from } doReturn null } + val content = contentCreator.createFromMessage(account, message) + assertThat(content.sender).isEqualTo("No sender") assertThat(content.summary.toString()).isEqualTo(SUBJECT) } @@ -111,15 +119,6 @@ class NotificationContentCreatorTest : RobolectricTest() { assertThat(content.summary.toString()).isEqualTo("To:Bob $SUBJECT") } - @Test - fun createFromMessage_withStarredMessage() { - stubbing(message) { - on { isSet(Flag.FLAGGED) } doReturn true - } - val content = contentCreator.createFromMessage(account, message) - assertThat(content.isStarred).isTrue() - } - @Test fun createFromMessage_withoutEmptyMessage() { stubbing(message) { @@ -144,7 +143,7 @@ class NotificationContentCreatorTest : RobolectricTest() { private fun createFakeAccount(): Account = mock() private fun createMessageReference(): MessageReference { - return MessageReference(ACCOUNT_UUID, FOLDER_ID, UID, null) + return MessageReference(ACCOUNT_UUID, FOLDER_ID, UID) } private fun createFakeLocalMessage(messageReference: MessageReference): LocalMessage { @@ -158,4 +157,3 @@ class NotificationContentCreatorTest : RobolectricTest() { } } } - diff --git a/app/core/src/test/java/com/fsck/k9/notification/NotificationDataTest.kt b/app/core/src/test/java/com/fsck/k9/notification/NotificationDataTest.kt index 989048e5f8801250d527cc7bb421b6bfe51a81dd..03310ddc727be75ef761f7b71492352c6f1076e4 100644 --- a/app/core/src/test/java/com/fsck/k9/notification/NotificationDataTest.kt +++ b/app/core/src/test/java/com/fsck/k9/notification/NotificationDataTest.kt @@ -15,7 +15,7 @@ private const val FOLDER_ID = 42L class NotificationDataTest : RobolectricTest() { private val account = createFakeAccount() - private val notificationData = NotificationData(account, 0) + private val notificationData = NotificationData(account) @Test fun testAddNotificationContent() { @@ -28,7 +28,7 @@ class NotificationDataTest : RobolectricTest() { val holder = result.notificationHolder assertThat(holder).isNotNull() - assertThat(holder.notificationId).isEqualTo(NotificationIds.getNewMailStackedNotificationId(account, 0)) + assertThat(holder.notificationId).isEqualTo(NotificationIds.getSingleMessageNotificationId(account, 0)) assertThat(holder.content).isEqualTo(content) } @@ -46,16 +46,18 @@ class NotificationDataTest : RobolectricTest() { val result = notificationData.addNotificationContent(createNotificationContent("9")) assertThat(result.shouldCancelNotification).isTrue() - assertThat(result.notificationId).isEqualTo(NotificationIds.getNewMailStackedNotificationId(account, 0)) + assertThat(result.notificationId).isEqualTo(NotificationIds.getSingleMessageNotificationId(account, 0)) } @Test fun testRemoveNotificationForMessage() { val content = createNotificationContent("1") notificationData.addNotificationContent(content) + val result = notificationData.removeNotificationForMessage(content.messageReference) + assertThat(result.isUnknownNotification).isFalse() - assertThat(result.notificationId).isEqualTo(NotificationIds.getNewMailStackedNotificationId(account, 0)) + assertThat(result.notificationId).isEqualTo(NotificationIds.getSingleMessageNotificationId(account, 0)) assertThat(result.shouldCreateNotification).isFalse() } @@ -77,17 +79,17 @@ class NotificationDataTest : RobolectricTest() { val result = notificationData.removeNotificationForMessage(latestContent.messageReference) assertThat(result.isUnknownNotification).isFalse() - assertThat(result.notificationId).isEqualTo(NotificationIds.getNewMailStackedNotificationId(account, 1)) + assertThat(result.notificationId).isEqualTo(NotificationIds.getSingleMessageNotificationId(account, 1)) assertThat(result.shouldCreateNotification).isTrue() assertNotNull(result.notificationHolder) { holder -> - assertThat(holder.notificationId).isEqualTo(NotificationIds.getNewMailStackedNotificationId(account, 1)) + assertThat(holder.notificationId).isEqualTo(NotificationIds.getSingleMessageNotificationId(account, 1)) assertThat(holder.content).isEqualTo(content) } } @Test fun testRemoveDoesNotLeakNotificationIds() { - for (i in 1..NotificationData.MAX_NUMBER_OF_STACKED_NOTIFICATIONS + 1) { + for (i in 1..NotificationData.MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS + 1) { val content = createNotificationContent(i.toString()) notificationData.addNotificationContent(content) notificationData.removeNotificationForMessage(content.messageReference) @@ -97,55 +99,23 @@ class NotificationDataTest : RobolectricTest() { @Test fun testNewMessagesCount() { assertThat(notificationData.newMessagesCount).isEqualTo(0) + val contentOne = createNotificationContent("1") notificationData.addNotificationContent(contentOne) assertThat(notificationData.newMessagesCount).isEqualTo(1) - val contentTwo = createNotificationContent("2") - notificationData.addNotificationContent(contentTwo) - assertThat(notificationData.newMessagesCount).isEqualTo(2) - } - @Test - fun testUnreadMessagesCount() { - val notificationData = NotificationData(account, 42) - assertThat(notificationData.unreadMessageCount).isEqualTo(42) - val content = createNotificationContent("1") - notificationData.addNotificationContent(content) - assertThat(notificationData.unreadMessageCount).isEqualTo(43) val contentTwo = createNotificationContent("2") notificationData.addNotificationContent(contentTwo) - assertThat(notificationData.unreadMessageCount).isEqualTo(44) - } - - @Test - fun testContainsStarredMessages() { - assertThat(notificationData.containsStarredMessages()).isFalse() - notificationData.addNotificationContent(createNotificationContentForStarredMessage()) - assertThat(notificationData.containsStarredMessages()).isTrue() - } - - @Test - fun testContainsStarredMessagesWithAdditionalMessages() { - notificationData.addNotificationContent(createNotificationContent("1")) - notificationData.addNotificationContent(createNotificationContent("2")) - notificationData.addNotificationContent(createNotificationContent("3")) - notificationData.addNotificationContent(createNotificationContent("4")) - notificationData.addNotificationContent(createNotificationContent("5")) - notificationData.addNotificationContent(createNotificationContent("6")) - notificationData.addNotificationContent(createNotificationContent("7")) - notificationData.addNotificationContent(createNotificationContent("8")) - assertThat(notificationData.containsStarredMessages()).isFalse() - - notificationData.addNotificationContent(createNotificationContentForStarredMessage()) - - assertThat(notificationData.containsStarredMessages()).isTrue() + assertThat(notificationData.newMessagesCount).isEqualTo(2) } @Test fun testIsSingleMessageNotification() { assertThat(notificationData.isSingleMessageNotification).isFalse() + notificationData.addNotificationContent(createNotificationContent("1")) assertThat(notificationData.isSingleMessageNotification).isTrue() + notificationData.addNotificationContent(createNotificationContent("2")) assertThat(notificationData.isSingleMessageNotification).isFalse() } @@ -154,7 +124,9 @@ class NotificationDataTest : RobolectricTest() { fun testGetHolderForLatestNotification() { val content = createNotificationContent("1") val addResult = notificationData.addNotificationContent(content) + val holder = notificationData.holderForLatestNotification + assertThat(holder).isEqualTo(addResult.notificationHolder) } @@ -186,10 +158,12 @@ class NotificationDataTest : RobolectricTest() { fun testGetActiveNotificationIds() { notificationData.addNotificationContent(createNotificationContent("1")) notificationData.addNotificationContent(createNotificationContent("2")) + val notificationIds = notificationData.getActiveNotificationIds() + assertThat(notificationIds.size).isEqualTo(2) - assertThat(notificationIds[0]).isEqualTo(NotificationIds.getNewMailStackedNotificationId(account, 1)) - assertThat(notificationIds[1]).isEqualTo(NotificationIds.getNewMailStackedNotificationId(account, 0)) + assertThat(notificationIds[0]).isEqualTo(NotificationIds.getSingleMessageNotificationId(account, 1)) + assertThat(notificationIds[1]).isEqualTo(NotificationIds.getSingleMessageNotificationId(account, 0)) } @Test @@ -267,7 +241,7 @@ class NotificationDataTest : RobolectricTest() { } private fun createMessageReference(uid: String): MessageReference { - return MessageReference(ACCOUNT_UUID, FOLDER_ID, uid, null) + return MessageReference(ACCOUNT_UUID, FOLDER_ID, uid) } private fun createNotificationContent(uid: String): NotificationContent { @@ -276,11 +250,12 @@ class NotificationDataTest : RobolectricTest() { } private fun createNotificationContent(messageReference: MessageReference): NotificationContent { - return NotificationContent(messageReference, "", "", "", "", false) - } - - private fun createNotificationContentForStarredMessage(): NotificationContent { - val messageReference = createMessageReference("42") - return NotificationContent(messageReference, "", "", "", "", true) + return NotificationContent( + messageReference = messageReference, + sender = "irrelevant", + subject = "irrelevant", + preview = "irrelevant", + summary = "irrelevant" + ) } -} \ No newline at end of file +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/NotificationIdsTest.kt b/app/core/src/test/java/com/fsck/k9/notification/NotificationIdsTest.kt index 4a39e06dc98720a25ca6a6c56914f87c2f50a013..c9f9dafcd38c575c1967b48cadb0826c962ba1dc 100644 --- a/app/core/src/test/java/com/fsck/k9/notification/NotificationIdsTest.kt +++ b/app/core/src/test/java/com/fsck/k9/notification/NotificationIdsTest.kt @@ -94,8 +94,8 @@ class NotificationIdsTest { NotificationIds.getAuthenticationErrorNotificationId(account, false), NotificationIds.getFetchingMailNotificationId(account), NotificationIds.getNewMailSummaryNotificationId(account), - ) + (0 until NotificationData.MAX_NUMBER_OF_STACKED_NOTIFICATIONS).map { index -> - NotificationIds.getNewMailStackedNotificationId(account, index) + ) + (0 until NotificationData.MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS).map { index -> + NotificationIds.getSingleMessageNotificationId(account, index) } } diff --git a/app/core/src/test/java/com/fsck/k9/notification/RemoveNotificationResultTest.kt b/app/core/src/test/java/com/fsck/k9/notification/RemoveNotificationResultTest.kt index 2bda81a6098dc6c8a6baaaac0232de9f49a0dde2..ad283570f025259af69d86d646f3e9cc0e01907b 100644 --- a/app/core/src/test/java/com/fsck/k9/notification/RemoveNotificationResultTest.kt +++ b/app/core/src/test/java/com/fsck/k9/notification/RemoveNotificationResultTest.kt @@ -13,12 +13,11 @@ class RemoveNotificationResultTest { private val notificationHolder = NotificationHolder( notificationId = NOTIFICATION_ID, content = NotificationContent( - messageReference = MessageReference("irrelevant", 1, "irrelevant", null), + messageReference = MessageReference("irrelevant", 1, "irrelevant"), sender = "irrelevant", subject = "irrelevant", preview = "irrelevant", - summary = "irrelevant", - isStarred = false + summary = "irrelevant" ) ) diff --git a/app/core/src/test/java/com/fsck/k9/notification/SendFailedNotificationControllerTest.kt b/app/core/src/test/java/com/fsck/k9/notification/SendFailedNotificationControllerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..39611110e3bdaa0665b788a7990cc36e42ddbf59 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/SendFailedNotificationControllerTest.kt @@ -0,0 +1,86 @@ +package com.fsck.k9.notification + +import android.app.Notification +import android.app.PendingIntent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.test.core.app.ApplicationProvider +import com.fsck.k9.Account +import com.fsck.k9.RobolectricTest +import com.fsck.k9.testing.MockHelper.mockBuilder +import org.junit.Test +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +private const val ACCOUNT_NUMBER = 1 +private const val ACCOUNT_NAME = "TestAccount" + +class SendFailedNotificationControllerTest : RobolectricTest() { + private val resourceProvider: NotificationResourceProvider = TestNotificationResourceProvider() + private val notification = mock() + private val notificationManager = mock() + private val builder = createFakeNotificationBuilder(notification) + private val account = createFakeAccount() + private val contentIntent = mock() + private val notificationId = NotificationIds.getSendFailedNotificationId(account) + private val controller = SendFailedNotificationController( + notificationHelper = createFakeNotificationHelper(notificationManager, builder), + actionBuilder = createActionBuilder(contentIntent), + resourceProvider = resourceProvider + ) + + @Test + fun testShowSendFailedNotification() { + val exception = Exception() + + controller.showSendFailedNotification(account, exception) + + verify(notificationManager).notify(notificationId, notification) + verify(builder).setSmallIcon(resourceProvider.iconWarning) + verify(builder).setTicker("Failed to send some messages") + verify(builder).setContentTitle("Failed to send some messages") + verify(builder).setContentText("Exception") + verify(builder).setContentIntent(contentIntent) + verify(builder).setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + } + + @Test + fun testClearSendFailedNotification() { + controller.clearSendFailedNotification(account) + + verify(notificationManager).cancel(notificationId) + } + + private fun createFakeNotificationBuilder(notification: Notification): NotificationCompat.Builder { + return mockBuilder { + on { build() } doReturn notification + } + } + + private fun createFakeNotificationHelper( + notificationManager: NotificationManagerCompat, + builder: NotificationCompat.Builder + ): NotificationHelper { + return mock { + on { getContext() } doReturn ApplicationProvider.getApplicationContext() + on { getNotificationManager() } doReturn notificationManager + on { createNotificationBuilder(any(), any()) } doReturn builder + } + } + + private fun createFakeAccount(): Account { + return mock { + on { accountNumber } doReturn ACCOUNT_NUMBER + on { description } doReturn ACCOUNT_NAME + } + } + + private fun createActionBuilder(contentIntent: PendingIntent): NotificationActionCreator { + return mock { + on { createViewFolderListPendingIntent(any(), anyInt()) } doReturn contentIntent + } + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/SingleMessageNotificationDataCreatorTest.kt b/app/core/src/test/java/com/fsck/k9/notification/SingleMessageNotificationDataCreatorTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..499dcf0233539875330cbb22e33b80e39798285d --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/SingleMessageNotificationDataCreatorTest.kt @@ -0,0 +1,274 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.K9 +import com.fsck.k9.K9.NotificationQuickDelete +import com.fsck.k9.controller.MessageReference +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class SingleMessageNotificationDataCreatorTest { + private val account = createAccount() + private val notificationDataCreator = SingleMessageNotificationDataCreator() + + @Test + fun `base properties`() { + val content = createNotificationContent() + + val result = notificationDataCreator.createSingleNotificationData( + account = account, + notificationId = 23, + content = content, + timestamp = 9000, + addLockScreenNotification = true + ) + + assertThat(result.notificationId).isEqualTo(23) + assertThat(result.isSilent).isTrue() + assertThat(result.timestamp).isEqualTo(9000) + assertThat(result.content).isEqualTo(content) + assertThat(result.addLockScreenNotification).isTrue() + } + + @Test + fun `summary notification base properties`() { + val content = createNotificationContent() + val notificationData = createNotificationData(content) + + val result = notificationDataCreator.createSummarySingleNotificationData( + timestamp = 9000, + silent = false, + data = notificationData + ) + + assertThat(result.singleNotificationData.notificationId).isEqualTo( + NotificationIds.getNewMailSummaryNotificationId(account) + ) + assertThat(result.singleNotificationData.isSilent).isFalse() + assertThat(result.singleNotificationData.timestamp).isEqualTo(9000) + assertThat(result.singleNotificationData.content).isEqualTo(content) + assertThat(result.singleNotificationData.addLockScreenNotification).isFalse() + } + + @Test + fun `default actions`() { + val content = createNotificationContent() + + val result = notificationDataCreator.createSingleNotificationData( + account = account, + notificationId = 0, + content = content, + timestamp = 0, + addLockScreenNotification = false + ) + + assertThat(result.actions).contains(NotificationAction.Reply) + assertThat(result.actions).contains(NotificationAction.MarkAsRead) + assertThat(result.wearActions).contains(WearNotificationAction.Reply) + assertThat(result.wearActions).contains(WearNotificationAction.MarkAsRead) + } + + @Test + fun `always show delete action without confirmation`() { + setDeleteAction(NotificationQuickDelete.ALWAYS) + setConfirmDeleteFromNotification(false) + val content = createNotificationContent() + + val result = notificationDataCreator.createSingleNotificationData( + account = account, + notificationId = 0, + content = content, + timestamp = 0, + addLockScreenNotification = false + ) + + assertThat(result.actions).contains(NotificationAction.Delete) + assertThat(result.wearActions).contains(WearNotificationAction.Delete) + } + + @Test + fun `always show delete action with confirmation`() { + setDeleteAction(NotificationQuickDelete.ALWAYS) + setConfirmDeleteFromNotification(true) + val content = createNotificationContent() + + val result = notificationDataCreator.createSingleNotificationData( + account = account, + notificationId = 0, + content = content, + timestamp = 0, + addLockScreenNotification = false + ) + + assertThat(result.actions).contains(NotificationAction.Delete) + assertThat(result.wearActions).doesNotContain(WearNotificationAction.Delete) + } + + @Test + fun `show delete action for single notification without confirmation`() { + setDeleteAction(NotificationQuickDelete.FOR_SINGLE_MSG) + setConfirmDeleteFromNotification(false) + val content = createNotificationContent() + + val result = notificationDataCreator.createSingleNotificationData( + account = account, + notificationId = 0, + content = content, + timestamp = 0, + addLockScreenNotification = false + ) + + assertThat(result.actions).contains(NotificationAction.Delete) + assertThat(result.wearActions).contains(WearNotificationAction.Delete) + } + + @Test + fun `show delete action for single notification with confirmation`() { + setDeleteAction(NotificationQuickDelete.FOR_SINGLE_MSG) + setConfirmDeleteFromNotification(true) + val content = createNotificationContent() + + val result = notificationDataCreator.createSingleNotificationData( + account = account, + notificationId = 0, + content = content, + timestamp = 0, + addLockScreenNotification = false + ) + + assertThat(result.actions).contains(NotificationAction.Delete) + assertThat(result.wearActions).doesNotContain(WearNotificationAction.Delete) + } + + @Test + fun `never show delete action`() { + setDeleteAction(NotificationQuickDelete.NEVER) + val content = createNotificationContent() + + val result = notificationDataCreator.createSingleNotificationData( + account = account, + notificationId = 0, + content = content, + timestamp = 0, + addLockScreenNotification = false + ) + + assertThat(result.actions).doesNotContain(NotificationAction.Delete) + assertThat(result.wearActions).doesNotContain(WearNotificationAction.Delete) + } + + @Test + fun `archive action with archive folder`() { + account.archiveFolderId = 1 + val content = createNotificationContent() + + val result = notificationDataCreator.createSingleNotificationData( + account = account, + notificationId = 0, + content = content, + timestamp = 0, + addLockScreenNotification = false + ) + + assertThat(result.wearActions).contains(WearNotificationAction.Archive) + } + + @Test + fun `archive action without archive folder`() { + account.archiveFolderId = null + val content = createNotificationContent() + + val result = notificationDataCreator.createSingleNotificationData( + account = account, + notificationId = 0, + content = content, + timestamp = 0, + addLockScreenNotification = false + ) + + assertThat(result.wearActions).doesNotContain(WearNotificationAction.Archive) + } + + @Test + fun `spam action with spam folder and without spam confirmation`() { + account.spamFolderId = 1 + setConfirmSpam(false) + val content = createNotificationContent() + + val result = notificationDataCreator.createSingleNotificationData( + account = account, + notificationId = 0, + content = content, + timestamp = 0, + addLockScreenNotification = false + ) + + assertThat(result.wearActions).contains(WearNotificationAction.Spam) + } + + @Test + fun `spam action with spam folder and with spam confirmation`() { + account.spamFolderId = 1 + setConfirmSpam(true) + val content = createNotificationContent() + + val result = notificationDataCreator.createSingleNotificationData( + account = account, + notificationId = 0, + content = content, + timestamp = 0, + addLockScreenNotification = false + ) + + assertThat(result.wearActions).doesNotContain(WearNotificationAction.Spam) + } + + @Test + fun `spam action without spam folder and without spam confirmation`() { + account.spamFolderId = null + setConfirmSpam(false) + val content = createNotificationContent() + + val result = notificationDataCreator.createSingleNotificationData( + account = account, + notificationId = 0, + content = content, + timestamp = 0, + addLockScreenNotification = false + ) + + assertThat(result.wearActions).doesNotContain(WearNotificationAction.Spam) + } + + private fun setDeleteAction(mode: NotificationQuickDelete) { + K9.notificationQuickDeleteBehaviour = mode + } + + private fun setConfirmDeleteFromNotification(confirm: Boolean) { + K9.isConfirmDeleteFromNotification = confirm + } + + private fun setConfirmSpam(confirm: Boolean) { + K9.isConfirmSpam = confirm + } + + private fun createAccount(): Account { + return Account("00000000-0000-0000-0000-000000000000").apply { + accountNumber = 42 + } + } + + private fun createNotificationContent() = NotificationContent( + messageReference = MessageReference("irrelevant", 1, "irrelevant"), + sender = "irrelevant", + subject = "irrelevant", + preview = "irrelevant", + summary = "irrelevant" + ) + + private fun createNotificationData(content: NotificationContent): NotificationData { + return NotificationData(account).apply { + addNotificationContent(content) + } + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/SummaryNotificationDataCreatorTest.kt b/app/core/src/test/java/com/fsck/k9/notification/SummaryNotificationDataCreatorTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..39e78c465b3cab76b2d11f1a9a2b23d25272b498 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/SummaryNotificationDataCreatorTest.kt @@ -0,0 +1,289 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.Clock +import com.fsck.k9.K9 +import com.fsck.k9.TestClock +import com.fsck.k9.controller.MessageReference +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module + +class SummaryNotificationDataCreatorTest { + private val account = createAccount() + private val notificationDataCreator = SummaryNotificationDataCreator(SingleMessageNotificationDataCreator()) + + @Before + fun setUp() { + startKoin { + modules( + module { + single { TestClock() } + } + ) + } + } + + @After + fun tearDown() { + stopKoin() + setQuietTime(false) + } + + @Test + fun `single new message`() { + val notificationData = createNotificationData() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + timestamp = 9000, + silent = false + ) + + assertThat(result).isInstanceOf(SummarySingleNotificationData::class.java) + } + + @Test + fun `single notification during quiet time`() { + setQuietTime(true) + val notificationData = createNotificationData() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + timestamp = 9000, + silent = false + ) + + val summaryNotificationData = result as SummarySingleNotificationData + assertThat(summaryNotificationData.singleNotificationData.isSilent).isTrue() + } + + @Test + fun `single notification with quiet time disabled`() { + setQuietTime(false) + val notificationData = createNotificationData() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + timestamp = 9000, + silent = false + ) + + val summaryNotificationData = result as SummarySingleNotificationData + assertThat(summaryNotificationData.singleNotificationData.isSilent).isFalse() + } + + @Test + fun `inbox-style notification during quiet time`() { + setQuietTime(true) + val notificationData = createNotificationDataWithMultipleMessages() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + timestamp = 9000, + silent = false + ) + + val summaryNotificationData = result as SummaryInboxNotificationData + assertThat(summaryNotificationData.isSilent).isTrue() + } + + @Test + fun `inbox-style notification with quiet time disabled`() { + setQuietTime(false) + val notificationData = createNotificationDataWithMultipleMessages() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + timestamp = 9000, + silent = false + ) + + val summaryNotificationData = result as SummaryInboxNotificationData + assertThat(summaryNotificationData.isSilent).isFalse() + } + + @Test + fun `inbox-style base properties`() { + val notificationData = createNotificationDataWithMultipleMessages() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + timestamp = 9000, + silent = true + ) + + val summaryNotificationData = result as SummaryInboxNotificationData + assertThat(summaryNotificationData.notificationId).isEqualTo( + NotificationIds.getNewMailSummaryNotificationId(account) + ) + assertThat(summaryNotificationData.isSilent).isTrue() + assertThat(summaryNotificationData.timestamp).isEqualTo(9000) + } + + @Test + fun `default actions`() { + val notificationData = createNotificationDataWithMultipleMessages() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + timestamp = 9000, + silent = true + ) + + val summaryNotificationData = result as SummaryInboxNotificationData + assertThat(summaryNotificationData.actions).contains(SummaryNotificationAction.MarkAsRead) + assertThat(summaryNotificationData.wearActions).contains(SummaryWearNotificationAction.MarkAsRead) + } + + @Test + fun `always show delete action without confirmation`() { + setDeleteAction(K9.NotificationQuickDelete.ALWAYS) + setConfirmDeleteFromNotification(false) + val notificationData = createNotificationDataWithMultipleMessages() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + timestamp = 9000, + silent = true + ) + + val summaryNotificationData = result as SummaryInboxNotificationData + assertThat(summaryNotificationData.actions).contains(SummaryNotificationAction.Delete) + assertThat(summaryNotificationData.wearActions).contains(SummaryWearNotificationAction.Delete) + } + + @Test + fun `always show delete action with confirmation`() { + setDeleteAction(K9.NotificationQuickDelete.ALWAYS) + setConfirmDeleteFromNotification(true) + val notificationData = createNotificationDataWithMultipleMessages() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + timestamp = 9000, + silent = true + ) + + val summaryNotificationData = result as SummaryInboxNotificationData + assertThat(summaryNotificationData.actions).contains(SummaryNotificationAction.Delete) + assertThat(summaryNotificationData.wearActions).doesNotContain(SummaryWearNotificationAction.Delete) + } + + @Test + fun `show delete action for single notification without confirmation`() { + setDeleteAction(K9.NotificationQuickDelete.FOR_SINGLE_MSG) + setConfirmDeleteFromNotification(false) + val notificationData = createNotificationDataWithMultipleMessages() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + timestamp = 9000, + silent = true + ) + + val summaryNotificationData = result as SummaryInboxNotificationData + assertThat(summaryNotificationData.actions).doesNotContain(SummaryNotificationAction.Delete) + assertThat(summaryNotificationData.wearActions).doesNotContain(SummaryWearNotificationAction.Delete) + } + + @Test + fun `never show delete action`() { + setDeleteAction(K9.NotificationQuickDelete.NEVER) + val notificationData = createNotificationDataWithMultipleMessages() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + timestamp = 9000, + silent = true + ) + + val summaryNotificationData = result as SummaryInboxNotificationData + assertThat(summaryNotificationData.actions).doesNotContain(SummaryNotificationAction.Delete) + assertThat(summaryNotificationData.wearActions).doesNotContain(SummaryWearNotificationAction.Delete) + } + + @Test + fun `archive action with archive folder`() { + account.archiveFolderId = 1 + val notificationData = createNotificationDataWithMultipleMessages() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + timestamp = 9000, + silent = true + ) + + val summaryNotificationData = result as SummaryInboxNotificationData + assertThat(summaryNotificationData.wearActions).contains(SummaryWearNotificationAction.Archive) + } + + @Test + fun `archive action without archive folder`() { + account.archiveFolderId = null + val notificationData = createNotificationDataWithMultipleMessages() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + timestamp = 9000, + silent = true + ) + + val summaryNotificationData = result as SummaryInboxNotificationData + assertThat(summaryNotificationData.wearActions).doesNotContain(SummaryWearNotificationAction.Archive) + } + + private fun setQuietTime(quietTime: Boolean) { + K9.isQuietTimeEnabled = quietTime + if (quietTime) { + K9.quietTimeStarts = "0:00" + K9.quietTimeEnds = "23:59" + } + } + + private fun setDeleteAction(mode: K9.NotificationQuickDelete) { + K9.notificationQuickDeleteBehaviour = mode + } + + private fun setConfirmDeleteFromNotification(confirm: Boolean) { + K9.isConfirmDeleteFromNotification = confirm + } + + private fun createAccount(): Account { + return Account("00000000-0000-0000-0000-000000000000").apply { + accountNumber = 42 + } + } + + private fun createNotificationContent() = NotificationContent( + messageReference = MessageReference("irrelevant", 1, "irrelevant"), + sender = "irrelevant", + subject = "irrelevant", + preview = "irrelevant", + summary = "irrelevant" + ) + + private fun createNotificationData( + contentList: List = listOf(createNotificationContent()) + ): NotificationData { + return NotificationData(account).apply { + for (content in contentList) { + addNotificationContent(content) + } + } + } + + @OptIn(ExperimentalStdlibApi::class) + private fun createNotificationDataWithMultipleMessages(times: Int = 2): NotificationData { + val contentList = buildList { + repeat(times) { + add(createNotificationContent()) + } + } + return createNotificationData(contentList) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/SyncNotificationControllerTest.kt b/app/core/src/test/java/com/fsck/k9/notification/SyncNotificationControllerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..9354ed4de63fc4e148c1bd66f119c47ebed8b21a --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/SyncNotificationControllerTest.kt @@ -0,0 +1,140 @@ +package com.fsck.k9.notification + +import android.app.Notification +import android.app.PendingIntent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.test.core.app.ApplicationProvider +import com.fsck.k9.Account +import com.fsck.k9.RobolectricTest +import com.fsck.k9.mailstore.LocalFolder +import com.fsck.k9.notification.NotificationIds.getFetchingMailNotificationId +import com.fsck.k9.testing.MockHelper.mockBuilder +import org.junit.Test +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock + +private const val ACCOUNT_NUMBER = 1 +private const val ACCOUNT_NAME = "TestAccount" +private const val FOLDER_SERVER_ID = "INBOX" +private const val FOLDER_NAME = "Inbox" + +class SyncNotificationControllerTest : RobolectricTest() { + private val resourceProvider: NotificationResourceProvider = TestNotificationResourceProvider() + private val notification = mock() + private val notificationManager = mock() + private val builder = createFakeNotificationBuilder(notification) + private val account = createFakeAccount() + private val contentIntent = mock() + private val controller = SyncNotificationController( + notificationHelper = createFakeNotificationHelper(notificationManager, builder), + actionBuilder = createActionBuilder(contentIntent), + resourceProvider = resourceProvider + ) + + @Test + fun testShowSendingNotification() { + val notificationId = getFetchingMailNotificationId(account) + + controller.showSendingNotification(account) + + verify(notificationManager).notify(notificationId, notification) + verify(builder).setSmallIcon(resourceProvider.iconSendingMail) + verify(builder).setTicker("Sending mail: $ACCOUNT_NAME") + verify(builder).setContentTitle("Sending mail") + verify(builder).setContentText(ACCOUNT_NAME) + verify(builder).setContentIntent(contentIntent) + verify(builder).setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + } + + @Test + fun testClearSendingNotification() { + val notificationId = getFetchingMailNotificationId(account) + + controller.clearSendingNotification(account) + + verify(notificationManager).cancel(notificationId) + } + + @Test + fun testGetFetchingMailNotificationId() { + val localFolder = createFakeLocalFolder() + val notificationId = getFetchingMailNotificationId(account) + + controller.showFetchingMailNotification(account, localFolder) + + verify(notificationManager).notify(notificationId, notification) + verify(builder).setSmallIcon(resourceProvider.iconCheckingMail) + verify(builder).setTicker("Checking mail: $ACCOUNT_NAME:$FOLDER_NAME") + verify(builder).setContentTitle("Checking mail") + verify(builder).setContentText("$ACCOUNT_NAME:$FOLDER_NAME") + verify(builder).setContentIntent(contentIntent) + verify(builder).setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + } + + @Test + fun testShowEmptyFetchingMailNotification() { + val notificationId = getFetchingMailNotificationId(account) + + controller.showEmptyFetchingMailNotification(account) + + verify(notificationManager).notify(notificationId, notification) + verify(builder).setSmallIcon(resourceProvider.iconCheckingMail) + verify(builder).setContentTitle("Checking mail") + verify(builder).setContentText(ACCOUNT_NAME) + verify(builder).setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + } + + @Test + fun testClearSendFailedNotification() { + val notificationId = getFetchingMailNotificationId(account) + + controller.clearFetchingMailNotification(account) + + verify(notificationManager).cancel(notificationId) + } + + private fun createFakeNotificationBuilder(notification: Notification): NotificationCompat.Builder { + return mockBuilder { + on { build() } doReturn notification + } + } + + private fun createFakeNotificationHelper( + notificationManager: NotificationManagerCompat, + builder: NotificationCompat.Builder + ): NotificationHelper { + return mock { + on { getContext() } doReturn ApplicationProvider.getApplicationContext() + on { getNotificationManager() } doReturn notificationManager + on { createNotificationBuilder(any(), any()) } doReturn builder + on { getAccountName(any()) } doReturn ACCOUNT_NAME + } + } + + private fun createFakeAccount(): Account { + return mock { + on { accountNumber } doReturn ACCOUNT_NUMBER + on { description } doReturn ACCOUNT_NAME + on { outboxFolderId } doReturn 33L + } + } + + private fun createActionBuilder(contentIntent: PendingIntent): NotificationActionCreator { + return mock { + on { createViewFolderPendingIntent(eq(account), anyLong(), anyInt()) } doReturn contentIntent + } + } + + private fun createFakeLocalFolder(): LocalFolder { + return mock { + on { serverId } doReturn FOLDER_SERVER_ID + on { name } doReturn FOLDER_NAME + } + } +} diff --git a/app/core/src/test/java/com/fsck/k9/preferences/SettingsExporterTest.kt b/app/core/src/test/java/com/fsck/k9/preferences/SettingsExporterTest.kt index d29f4cd9f9ecdd75b51c87d8aff215d783119b5b..b92e017ba347e0ca4f0e637474d8893e2b3d7fe2 100644 --- a/app/core/src/test/java/com/fsck/k9/preferences/SettingsExporterTest.kt +++ b/app/core/src/test/java/com/fsck/k9/preferences/SettingsExporterTest.kt @@ -2,10 +2,10 @@ package com.fsck.k9.preferences import com.fsck.k9.K9RobolectricTest import com.fsck.k9.Preferences +import com.fsck.k9.mailstore.FolderRepository import java.io.ByteArrayOutputStream import org.jdom2.Document import org.jdom2.input.SAXBuilder -import com.fsck.k9.mailstore.FolderRepository import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull diff --git a/app/k9mail-jmap/build.gradle b/app/k9mail-jmap/build.gradle index b6a3cf29f34f3bf367f6eeb4ec5dcc53bfc4b2e8..7f4cb270d43dfcc4a690360321d6254fe9d311b3 100644 --- a/app/k9mail-jmap/build.gradle +++ b/app/k9mail-jmap/build.gradle @@ -38,7 +38,6 @@ dependencies { testImplementation "org.mockito.kotlin:mockito-kotlin:${versions.mockitoKotlin}" testImplementation "org.koin:koin-test:${versions.koin}" } - android { compileSdkVersion buildConfig.compileSdk buildToolsVersion buildConfig.buildTools diff --git a/app/k9mail-jmap/src/main/AndroidManifest.xml b/app/k9mail-jmap/src/main/AndroidManifest.xml index 02d83ec046ba8005262d70f62075f3bc895729d7..5ec0d38397f71c3f8eef0881987dad897cca8c64 100644 --- a/app/k9mail-jmap/src/main/AndroidManifest.xml +++ b/app/k9mail-jmap/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ @@ -32,7 +33,7 @@ android:theme="@style/Theme.K9.Startup" android:usesCleartextTraffic="true"> - diff --git a/app/k9mail-jmap/src/main/java/com/fsck/k9/notification/K9NotificationActionCreator.kt b/app/k9mail-jmap/src/main/java/com/fsck/k9/notification/K9NotificationActionCreator.kt index ce5bb48dd8f6f4e1366729287d31dcdc793563df..a18f1ddf58033ad19db1628ef99822409eff6fe1 100644 --- a/app/k9mail-jmap/src/main/java/com/fsck/k9/notification/K9NotificationActionCreator.kt +++ b/app/k9mail-jmap/src/main/java/com/fsck/k9/notification/K9NotificationActionCreator.kt @@ -69,7 +69,6 @@ internal class K9NotificationActionCreator( } override fun createDismissMessagePendingIntent( - context: Context, messageReference: MessageReference, notificationId: Int ): PendingIntent { diff --git a/app/k9mail-jmap/src/main/java/com/fsck/k9/resources/K9CoreResourceProvider.kt b/app/k9mail-jmap/src/main/java/com/fsck/k9/resources/K9CoreResourceProvider.kt index 8f2f61fa2ab55d5abf4fa5ff7634ca1655825575..5a11b2251cbf39782649c6337b22acf8311cc134 100644 --- a/app/k9mail-jmap/src/main/java/com/fsck/k9/resources/K9CoreResourceProvider.kt +++ b/app/k9mail-jmap/src/main/java/com/fsck/k9/resources/K9CoreResourceProvider.kt @@ -9,12 +9,6 @@ class K9CoreResourceProvider(private val context: Context) : CoreResourceProvide override fun defaultSignature(): String = context.getString(R.string.default_signature) override fun defaultIdentityDescription(): String = context.getString(R.string.default_identity_description) - override fun internalStorageProviderName(): String = - context.getString(R.string.local_storage_provider_internal_label) - - override fun externalStorageProviderName(): String = - context.getString(R.string.local_storage_provider_external_label) - override fun contactDisplayNamePrefix(): String = context.getString(R.string.message_to_label) override fun contactUnknownSender(): String = context.getString(R.string.unknown_sender) override fun contactUnknownRecipient(): String = context.getString(R.string.unknown_recipient) diff --git a/app/k9mail-jmap/src/main/res/values/themes.xml b/app/k9mail-jmap/src/main/res/values/themes.xml index 7489edf82a645f2b8457e285aad7062d361a5d9c..700b046c83e1ea06bdcda35a463f0209ef1f4e38 100644 --- a/app/k9mail-jmap/src/main/res/values/themes.xml +++ b/app/k9mail-jmap/src/main/res/values/themes.xml @@ -53,6 +53,7 @@ @drawable/ic_file_upload @drawable/ic_select_all @drawable/ic_floppy + @drawable/ic_download @drawable/ic_clear @drawable/ic_action_request_read_receipt_light @drawable/ic_chevron_down @@ -92,6 +93,7 @@ @drawable/ic_messagelist_forwarded @drawable/ic_messagelist_answered_forwarded @drawable/btn_check_star + #fbbc04 #ffffffff @drawable/ic_person_plus #e8e8e8 @@ -170,6 +172,7 @@ @drawable/ic_file_upload @drawable/ic_select_all @drawable/ic_floppy + @drawable/ic_download @drawable/ic_clear @drawable/ic_action_request_read_receipt_dark @drawable/ic_chevron_down @@ -209,6 +212,7 @@ @drawable/ic_messagelist_forwarded @drawable/ic_messagelist_answered_forwarded @drawable/btn_check_star + #fdd663 #000000 @drawable/ic_person_plus #313131 diff --git a/app/k9mail-jmap/src/test/java/com/fsck/k9/DependencyInjectionTest.kt b/app/k9mail-jmap/src/test/java/com/fsck/k9/DependencyInjectionTest.kt index 880498b6405b33cf5f0c0f8a40e5a7557d88417a..02c8408aab323e7a0efdbc3a034358fc76cb346c 100644 --- a/app/k9mail-jmap/src/test/java/com/fsck/k9/DependencyInjectionTest.kt +++ b/app/k9mail-jmap/src/test/java/com/fsck/k9/DependencyInjectionTest.kt @@ -44,4 +44,4 @@ class DependencyInjectionTest : AutoCloseKoinTest() { create { parametersOf(ChangeLogMode.CHANGE_LOG) } } } -} +} \ No newline at end of file diff --git a/app/k9mail/build.gradle b/app/k9mail/build.gradle index 4fe29b4a318db7a2c7ade850227c2d7633ac80e2..bcec5ff80db4ab0bf669862bfcbe5619b204598e 100644 --- a/app/k9mail/build.gradle +++ b/app/k9mail/build.gradle @@ -47,8 +47,8 @@ android { applicationId "foundation.e.mail" testApplicationId "foundation.e.mail.tests" - versionCode 29002 - versionName '5.903-SNAPSHOT' + versionCode 29005 + versionName '5.905' // 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/src/main/AndroidManifest.xml b/app/k9mail/src/main/AndroidManifest.xml index 106f66347ad2f05f586d354d9c816eb2701e6175..3c819e4a11d654cef7cfe15c408e3226456a7047 100644 --- a/app/k9mail/src/main/AndroidManifest.xml +++ b/app/k9mail/src/main/AndroidManifest.xml @@ -1,125 +1,126 @@ - + + android:required="false" /> + android:smallScreens="true" /> - - - + + + - - - - - + + + + + - + android:protectionLevel="dangerous" /> + - + android:protectionLevel="dangerous" /> + + android:roundIcon="@mipmap/icon_e" + android:theme="@style/Theme.K9.Startup" + android:usesCleartextTraffic="true"> + android:value="com.fsck.k9.activity.Search" /> + android:value="true" /> + android:required="false" /> + + android:value="true" /> - + + android:exported="true" + android:targetActivity=".activity.MessageList" /> + android:theme="@style/Theme.K9.Dialog.Translucent.DayNight" /> + android:label="@string/account_setup_basics_title" /> + android:label="@string/account_setup_account_type_title" /> + android:label="@string/account_setup_incoming_title" /> + android:label="@string/account_settings_composition_title" /> + android:label="@string/account_setup_outgoing_title" /> + android:label="@string/account_setup_options_title" /> + android:label="@string/account_setup_names_title" /> + android:label="@string/manage_identities_title" /> + android:label="@string/edit_identity_title" /> + android:theme="@style/Theme.K9.Dialog.Translucent.DayNight" /> + android:label="@string/account_setup_check_settings_title" /> + android:label="@string/ac_transfer_title" /> + android:exported="true" + android:targetActivity=".activity.MessageList" /> - + - - - + + + - - + + - + - + android:scheme="k9mail" /> + @@ -202,30 +202,30 @@ android:enabled="false" android:label="@string/app_name"> - - - + + + - - - + + + - - - + + + - - + + - - + + - - + + - - + + + android:resource="@xml/searchable" /> - - + + - + + android:label="@string/upgrade_databases_title" /> - - - - - - - - - - + + android:resource="@xml/unread_widget_info" /> + android:enabled="false" + android:exported="false"> - + @@ -368,16 +344,16 @@ + android:enabled="true" /> + android:exported="false" /> + android:permission="android.permission.BIND_CHOOSER_TARGET_SERVICE"> @@ -385,7 +361,7 @@ + android:permission="android.permission.BIND_JOB_SERVICE" /> + android:writePermission="${applicationId}.permission.DELETE_MESSAGES" /> + android:exported="false" /> + android:orientation="vertical" + tools:context=".widget.unread.UnreadWidgetConfigurationActivity"> diff --git a/app/k9mail/src/main/res/values/themes.xml b/app/k9mail/src/main/res/values/themes.xml index 3d9a2729b7f1a752c257b657506cf28f88ba6fd9..becc4383ee63f95d2675850a82ae71c91f63dde0 100644 --- a/app/k9mail/src/main/res/values/themes.xml +++ b/app/k9mail/src/main/res/values/themes.xml @@ -50,7 +50,7 @@ @lineageos.platform:drawable/ic_star_filled @lineageos.platform:drawable/ic_star @drawable/ic_opened_envelope - @lineageos.platform:@drawable/ic_mark_new + @drawable/ic_mark_new @drawable/ic_magnify_cloud @lineageos.platform:drawable/ic_add @drawable/ic_arrow_up_down @@ -169,7 +169,7 @@ @lineageos.platform:drawable/ic_star_filled @lineageos.platform:drawable/ic_star @drawable/ic_opened_envelope - @lineageos.platform:drawable/ic_mark_new + @drawable/ic_mark_new @drawable/ic_magnify_cloud @lineageos.platform:drawable/ic_add @drawable/ic_arrow_up_down diff --git a/app/k9mail/src/release/java/app/k9mail/dev/ReleaseConfig.kt b/app/k9mail/src/release/java/app/k9mail/dev/ReleaseConfig.kt new file mode 100644 index 0000000000000000000000000000000000000000..930bc8a3fdbe1fac00513e39ce8d35bd2699bd38 --- /dev/null +++ b/app/k9mail/src/release/java/app/k9mail/dev/ReleaseConfig.kt @@ -0,0 +1,9 @@ +package app.k9mail.dev + +import com.fsck.k9.backend.BackendFactory +import org.koin.core.module.Module +import org.koin.core.scope.Scope + +fun Scope.developmentBackends() = emptyMap() + +fun Module.developmentModuleAdditions() = Unit diff --git a/app/k9mail/src/test/java/com/fsck/k9/DependencyInjectionTest.kt b/app/k9mail/src/test/java/com/fsck/k9/DependencyInjectionTest.kt index 880498b6405b33cf5f0c0f8a40e5a7557d88417a..02c8408aab323e7a0efdbc3a034358fc76cb346c 100644 --- a/app/k9mail/src/test/java/com/fsck/k9/DependencyInjectionTest.kt +++ b/app/k9mail/src/test/java/com/fsck/k9/DependencyInjectionTest.kt @@ -44,4 +44,4 @@ class DependencyInjectionTest : AutoCloseKoinTest() { create { parametersOf(ChangeLogMode.CHANGE_LOG) } } } -} +} \ No newline at end of file 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 210f905bbe2dcfbf5929a1ff9978fe972e4abd9f..abfc6a72df4368b649687d9f3ea5b2bbccfcb20c 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 @@ -134,6 +134,10 @@ class K9MessageStore( return retrieveFolderOperations.getFolderId(folderServerId) } + override fun getFolderServerId(folderId: Long): String? { + return retrieveFolderOperations.getFolderServerId(folderId) + } + override fun getMessageCount(folderId: Long): Int { return retrieveFolderOperations.getMessageCount(folderId) } @@ -175,7 +179,7 @@ class K9MessageStore( } override fun setLastChecked(folderId: Long, timestamp: Long) { - TODO("Not yet implemented") + updateFolderOperations.setLastChecked(folderId, timestamp) } override fun setStatus(folderId: Long, status: String?) { 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 37e5e49cae4c733b81d7b138356cc67955a24a59..eacaed3ab812218bd0943272fcdd8ba2cb428596 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 @@ -1,6 +1,7 @@ package com.fsck.k9.storage.messages import android.database.Cursor +import androidx.core.database.getLongOrNull import com.fsck.k9.Account.FolderMode import com.fsck.k9.helper.map import com.fsck.k9.mail.FolderClass @@ -131,6 +132,22 @@ internal class RetrieveFolderOperations(private val lockableDatabase: LockableDa } } + fun getFolderServerId(folderId: Long): String? { + return lockableDatabase.execute(false) { db -> + db.query( + "folders", + arrayOf("server_id"), + "id = ?", + arrayOf(folderId.toString()), + null, + null, + null + ).use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } + } + } + fun getMessageCount(folderId: Long): Int { return lockableDatabase.execute(false) { db -> db.rawQuery( diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/SaveMessageOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/SaveMessageOperations.kt index 827644bfcc9df3891009e37d146e8805ab5a3324..c6e95bc7b2094b316a80f98ea60f31526fb13920 100644 --- a/app/storage/src/main/java/com/fsck/k9/storage/messages/SaveMessageOperations.kt +++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/SaveMessageOperations.kt @@ -28,7 +28,6 @@ import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException import java.io.InputStream -import java.util.Locale import java.util.Stack import java.util.UUID import org.apache.commons.io.IOUtils @@ -306,9 +305,10 @@ internal class SaveMessageOperations( private fun decodeAndCountBytes(rawInputStream: InputStream, encoding: String, fallbackValue: Long): Long { return try { getDecodingInputStream(rawInputStream, encoding).use { decodingInputStream -> - val countingOutputStream = CountingOutputStream() - IOUtils.copy(decodingInputStream, countingOutputStream) - countingOutputStream.count + CountingOutputStream().use { countingOutputStream -> + IOUtils.copy(decodingInputStream, countingOutputStream) + countingOutputStream.count + } } } catch (e: IOException) { fallbackValue diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/ThreadMessageOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/ThreadMessageOperations.kt index cb503dd65ad0f4754b32a39bef2559311da12547..52544b1f4b4fc9b2ea569170b5caacbabd9ee584 100644 --- a/app/storage/src/main/java/com/fsck/k9/storage/messages/ThreadMessageOperations.kt +++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/ThreadMessageOperations.kt @@ -5,7 +5,6 @@ import android.database.sqlite.SQLiteDatabase import com.fsck.k9.helper.Utility import com.fsck.k9.mail.Message import com.fsck.k9.mail.message.MessageHeaderParser -import java.util.Locale internal class ThreadMessageOperations { diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo80.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo80.kt index 3cc4cc30c34d618ffed99877f730e85363b6d6e0..c435d9ead49f9714e232022db17aa6b825fbf0f0 100644 --- a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo80.kt +++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo80.kt @@ -9,4 +9,4 @@ internal class MigrationTo80(private val db: SQLiteDatabase) { fun rewriteLastUpdatedColumn() { db.execSQL("UPDATE folders SET last_updated = NULL WHERE last_updated = 0") } -} \ No newline at end of file +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/StoreSchemaDefinitionTest.java b/app/storage/src/test/java/com/fsck/k9/storage/StoreSchemaDefinitionTest.java index c7e4b0e88c52ffaeb7abd3dc12f5b3e3622ca6b7..7d103d3378357d9069191208dab00f584cf78635 100644 --- a/app/storage/src/test/java/com/fsck/k9/storage/StoreSchemaDefinitionTest.java +++ b/app/storage/src/test/java/com/fsck/k9/storage/StoreSchemaDefinitionTest.java @@ -31,8 +31,8 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; -import static org.mockito.Matchers.any; -import static org.mockito.Matchers.anyBoolean; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; 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 45b47396884c90733f882d66fa5c25464521e4a0..69fc55cbb5860e55b68f831ce86584d0602dc337 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 @@ -302,6 +302,25 @@ class RetrieveFolderOperationsTest : RobolectricTest() { assertThat(result).isNull() } + @Test + fun `get folder server id`() { + val (_, folderId2) = listOf( + sqliteDatabase.createFolder(serverId = "folder1"), + sqliteDatabase.createFolder(serverId = "folder2"), + ) + + val result = retrieveFolderOperations.getFolderServerId(folderId2) + + assertThat(result).isEqualTo("folder2") + } + + @Test + fun `get folder server id should return null if no folder was found`() { + val result = retrieveFolderOperations.getFolderServerId(folderId = 1) + + assertThat(result).isNull() + } + @Test fun `get message count from empty folder`() { val folderId = sqliteDatabase.createFolder() diff --git a/app/testing/src/main/java/com/fsck/k9/TestClock.kt b/app/testing/src/main/java/com/fsck/k9/TestClock.kt new file mode 100644 index 0000000000000000000000000000000000000000..3421c3e24e533ac4451c03c401d4104c444a086f --- /dev/null +++ b/app/testing/src/main/java/com/fsck/k9/TestClock.kt @@ -0,0 +1,5 @@ +package com.fsck.k9 + +class TestClock(initialTime: Long = 0L) : Clock { + override var time: Long = initialTime +} diff --git a/app/testing/src/main/java/com/fsck/k9/testing/MockHelper.kt b/app/testing/src/main/java/com/fsck/k9/testing/MockHelper.kt index 3334063d6182a7723b74f2142d627a6a28097814..17e9e6986e5e593d195b2ed95a3aa6377e9b1e8b 100644 --- a/app/testing/src/main/java/com/fsck/k9/testing/MockHelper.kt +++ b/app/testing/src/main/java/com/fsck/k9/testing/MockHelper.kt @@ -20,4 +20,4 @@ object MockHelper { inline fun mockBuilder(stubbing: KStubbing.(T) -> Unit = {}): T { return mockBuilder(T::class.java).apply { KStubbing(this).stubbing(this) } } -} \ No newline at end of file +} diff --git a/app/ui/base/build.gradle b/app/ui/base/build.gradle index eb7661cc2e74d5d3034427adf2ba9b481376ff15..6b94ff2b65c822963c0c1a44d42e06b049ef0c0a 100644 --- a/app/ui/base/build.gradle +++ b/app/ui/base/build.gradle @@ -5,6 +5,7 @@ dependencies { implementation project(":app:core") api "androidx.appcompat:appcompat:${versions.androidxAppCompat}" + api "androidx.activity:activity:${versions.androidxActivity}" api "com.google.android.material:material:${versions.materialComponents}" api "androidx.navigation:navigation-fragment-ktx:${versions.androidxNavigation}" api "androidx.navigation:navigation-ui-ktx:${versions.androidxNavigation}" diff --git a/app/ui/base/src/main/res/color/star_color.xml b/app/ui/base/src/main/res/color/star_color.xml index 63583273b80eaaa76baf7748d04e9cbf1c1ba338..ac2f37f0598e8dde66726391099e19195d9422b3 100644 --- a/app/ui/base/src/main/res/color/star_color.xml +++ b/app/ui/base/src/main/res/color/star_color.xml @@ -1,5 +1,5 @@ - + diff --git a/app/ui/base/src/main/res/values/styles.xml b/app/ui/base/src/main/res/values/styles.xml index 72ac1768599686230cd9a4cfe7e38bb990d4bff5..004d8134bc3285ef961210e0101d60c53152f75d 100644 --- a/app/ui/base/src/main/res/values/styles.xml +++ b/app/ui/base/src/main/res/values/styles.xml @@ -1,6 +1,7 @@ + \ No newline at end of file diff --git a/app/ui/legacy/build.gradle b/app/ui/legacy/build.gradle index 6805546bd650957e5e565606f55d429c6f5c2e18..8e14957d3b6703a1d241db8fa333e145eca4de21 100644 --- a/app/ui/legacy/build.gradle +++ b/app/ui/legacy/build.gradle @@ -23,6 +23,7 @@ dependencies { implementation "com.takisoft.preferencex:preferencex-colorpicker:${versions.preferencesFix}" implementation "com.takisoft.preferencex:preferencex-ringtone:${versions.preferencesFix}" implementation "androidx.recyclerview:recyclerview:${versions.androidxRecyclerView}" + implementation "androidx.lifecycle:lifecycle-runtime-ktx:${versions.androidxLifecycle}" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:${versions.androidxLifecycle}" implementation "androidx.lifecycle:lifecycle-livedata-ktx:${versions.androidxLifecycle}" implementation "androidx.constraintlayout:constraintlayout:${versions.androidxConstraintLayout}" @@ -63,6 +64,8 @@ dependencies { testImplementation "org.mockito:mockito-core:${versions.mockito}" testImplementation "org.mockito.kotlin:mockito-kotlin:${versions.mockitoKotlin}" testImplementation "org.koin:koin-test:${versions.koin}" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:${versions.kotlinCoroutines}" + testImplementation "app.cash.turbine:turbine:${versions.turbine}" } android { diff --git a/app/ui/legacy/sampledata/accounts.json b/app/ui/legacy/sampledata/accounts.json new file mode 100644 index 0000000000000000000000000000000000000000..7f5102272cf5bd4e6f0cdf7c1c84af807968572b --- /dev/null +++ b/app/ui/legacy/sampledata/accounts.json @@ -0,0 +1,19 @@ +{ + "data": [ + { + "name": "Personal", + "email": "user@domain.example", + "color": "#FF1976D2" + }, + { + "name": "Work", + "email": "firstname.lastname@work.example", + "color": "#FFE91E63" + }, + { + "name": "Club", + "email": "name@sportsclub.example", + "color": "#FFFFB300" + } + ] +} diff --git a/app/ui/legacy/sampledata/folders.json b/app/ui/legacy/sampledata/folders.json new file mode 100644 index 0000000000000000000000000000000000000000..e02d14f4e6793ae3c0544874633ddfcbb2c3bdce --- /dev/null +++ b/app/ui/legacy/sampledata/folders.json @@ -0,0 +1,48 @@ +{ + "data": [ + { + "name": "Inbox", + "icon": "?attr/iconFolderInbox" + }, + { + "name": "Outbox", + "icon": "?attr/iconFolderOutbox" + }, + { + "name": "Archive", + "icon": "?attr/iconFolderArchive" + }, + { + "name": "Drafts", + "icon": "?attr/iconFolderDrafts" + }, + { + "name": "Sent", + "icon": "?attr/iconFolderSent" + }, + { + "name": "Spam", + "icon": "?attr/iconFolderSpam" + }, + { + "name": "Trash", + "icon": "?attr/iconFolderTrash" + }, + { + "name": "Regular folder", + "icon": "?attr/iconFolder" + }, + { + "name": "Another folder", + "icon": "?attr/iconFolder" + }, + { + "name": "And yet another folder", + "icon": "?attr/iconFolder" + }, + { + "name": "Folder", + "icon": "?attr/iconFolder" + } + ] +} diff --git a/app/ui/legacy/src/debug/java/com/fsck/k9/ui/settings/ExtraAccountDiscovery.kt b/app/ui/legacy/src/debug/java/com/fsck/k9/ui/settings/ExtraAccountDiscovery.kt new file mode 100644 index 0000000000000000000000000000000000000000..df09c4e2607075d25dc05aa52b017dd5bdd8a0af --- /dev/null +++ b/app/ui/legacy/src/debug/java/com/fsck/k9/ui/settings/ExtraAccountDiscovery.kt @@ -0,0 +1,27 @@ +package com.fsck.k9.ui.settings + +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.ui.ConnectionSettings + +object ExtraAccountDiscovery { + @JvmStatic + fun discover(email: String): ConnectionSettings? { + return if (email.endsWith("@k9mail.example")) { + val serverSettings = ServerSettings( + type = "demo", + host = "irrelevant", + port = 23, + connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED, + authenticationType = AuthType.AUTOMATIC, + username = "irrelevant", + password = "irrelevant", + clientCertificateAlias = null + ) + ConnectionSettings(incoming = serverSettings, outgoing = serverSettings) + } else { + null + } + } +} diff --git a/app/ui/legacy/src/main/AndroidManifest.xml b/app/ui/legacy/src/main/AndroidManifest.xml index e734d8075cd250490df6c415276aac7f1003c85e..3b37d7b533c23dc642e59b4b5918de44027d7600 100644 --- a/app/ui/legacy/src/main/AndroidManifest.xml +++ b/app/ui/legacy/src/main/AndroidManifest.xml @@ -2,6 +2,13 @@ + + + + + + + diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/account/AccountCreator.kt b/app/ui/legacy/src/main/java/com/fsck/k9/account/AccountCreator.kt index 7c1e18ecde7e3af66b2b70fd038d6a3a968eb465..e3f49d16a919d845d5ffe629692dad9846768ae2 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/account/AccountCreator.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/account/AccountCreator.kt @@ -20,6 +20,7 @@ class AccountCreator(private val preferences: Preferences, private val resources Protocols.IMAP -> DeletePolicy.ON_DELETE Protocols.POP3 -> DeletePolicy.NEVER Protocols.WEBDAV -> DeletePolicy.ON_DELETE + "demo" -> DeletePolicy.ON_DELETE else -> throw AssertionError("Unhandled case: $type") } } @@ -60,10 +61,10 @@ class AccountCreator(private val preferences: Preferences, private val resources return accountColors.random() } - return availableColors.shuffled().minOf { color -> + return availableColors.shuffled().minByOrNull { color -> val index = DEFAULT_COLORS.indexOf(color) if (index != -1) index else DEFAULT_COLORS.size - } + } ?: error("availableColors must not be empty") } companion object { diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/EditIdentity.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/EditIdentity.kt index a7935fe22366b22efa28cc56e21f0e7e6ee975e4..1992c1cb27ef6c52370c183332f2863a9eabe15d 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/EditIdentity.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/EditIdentity.kt @@ -101,7 +101,7 @@ class EditIdentity : K9Activity() { outState.putParcelable(EXTRA_IDENTITY, identity) } - override fun onCreateOptionsMenu(menu: Menu?): Boolean { + override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.edit_identity_menu, menu) return true } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java index 3a8ae301a42f54d95814ed0a25418ef0b15380b3..89998ed4d2faf518694b781aee6234e1b6034f95 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java @@ -25,11 +25,9 @@ import android.os.AsyncTask; import android.os.Bundle; import android.os.Handler; import android.os.Parcelable; - import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.appcompat.app.ActionBar; - import android.text.TextUtils; import android.text.TextWatcher; import android.util.TypedValue; @@ -69,6 +67,8 @@ import com.fsck.k9.activity.compose.PgpInlineDialog.OnOpenPgpInlineChangeListene import com.fsck.k9.activity.compose.PgpSignOnlyDialog.OnOpenPgpSignOnlyChangeListener; import com.fsck.k9.activity.compose.RecipientMvpView; import com.fsck.k9.activity.compose.RecipientPresenter; +import com.fsck.k9.activity.compose.ReplyToPresenter; +import com.fsck.k9.activity.compose.ReplyToView; import com.fsck.k9.activity.compose.SaveMessageTask; import com.fsck.k9.activity.misc.Attachment; import com.fsck.k9.autocrypt.AutocryptDraftStateHeaderParser; @@ -205,6 +205,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, // relates to the message being replied to, forwarded, or edited TODO split up? private MessageReference relatedMessageReference; + private Flag relatedFlag = null; /** * Indicates that the source message has been processed at least once and should not be processed on any subsequent * loads. This protects us from adding attachments that have already been added from the restore of the view state. @@ -313,7 +314,6 @@ public class MessageCompose extends K9Activity implements OnClickListener, ReplyToView replyToView = new ReplyToView(this); replyToPresenter = new ReplyToPresenter(replyToView); - RecipientMvpView recipientMvpView = new RecipientMvpView(this); ComposePgpInlineDecider composePgpInlineDecider = new ComposePgpInlineDecider(); ComposePgpEnableByDefaultDecider composePgpEnableByDefaultDecider = new ComposePgpEnableByDefaultDecider(); @@ -449,7 +449,9 @@ public class MessageCompose extends K9Activity implements OnClickListener, } if (action == Action.REPLY || action == Action.REPLY_ALL) { - relatedMessageReference = relatedMessageReference.withModifiedFlag(Flag.ANSWERED); + relatedFlag = Flag.ANSWERED; + } else if (action == Action.FORWARD || action == Action.FORWARD_AS_ATTACHMENT) { + relatedFlag = Flag.FORWARDED; } if (action == Action.REPLY || action == Action.REPLY_ALL || @@ -461,10 +463,6 @@ public class MessageCompose extends K9Activity implements OnClickListener, recipientMvpView.requestFocusOnToField(); } - if (action == Action.FORWARD || action == Action.FORWARD_AS_ATTACHMENT) { - relatedMessageReference = relatedMessageReference.withModifiedFlag(Flag.FORWARDED); - } - updateMessageFormat(); // Set font size of input controls @@ -1234,10 +1232,11 @@ public class MessageCompose extends K9Activity implements OnClickListener, } /** - * Pull out the parts of the now loaded source message and apply them to the new message depending on the type of - * message being composed. + * Pull out the parts of the now loaded source message and apply them to the new message + * depending on the type of message being composed. * - * @param messageViewInfo The source message used to populate the various text fields. + * @param messageViewInfo + * The source message used to populate the various text fields. */ private void processSourceMessage(MessageViewInfo messageViewInfo) { try { @@ -1458,10 +1457,11 @@ public class MessageCompose extends K9Activity implements OnClickListener, final Long draftId; final String plaintextSubject; final MessageReference messageReference; + final Flag flag; SendMessageTask(MessagingController messagingController, Preferences preferences, Account account, Contacts contacts, Message message, Long draftId, String plaintextSubject, - MessageReference messageReference) { + MessageReference messageReference, Flag flag) { this.messagingController = messagingController; this.preferences = preferences; this.account = account; @@ -1470,6 +1470,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, this.draftId = draftId; this.plaintextSubject = plaintextSubject; this.messageReference = messageReference; + this.flag = flag; } @Override @@ -1478,7 +1479,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, contacts.markAsContacted(message.getRecipients(RecipientType.TO)); contacts.markAsContacted(message.getRecipients(RecipientType.CC)); contacts.markAsContacted(message.getRecipients(RecipientType.BCC)); - updateReferencedMessage(); + addFlagToReferencedMessage(); } catch (Exception e) { Timber.e(e, "Failed to mark contact as contacted."); } @@ -1495,13 +1496,12 @@ public class MessageCompose extends K9Activity implements OnClickListener, /** * Set the flag on the referenced message(indicated we replied / forwarded the message) **/ - private void updateReferencedMessage() { - if (messageReference != null && messageReference.getFlag() != null) { + private void addFlagToReferencedMessage() { + if (messageReference != null && flag != null) { String accountUuid = messageReference.getAccountUuid(); Account account = preferences.getAccount(accountUuid); long folderId = messageReference.getFolderId(); String sourceMessageUid = messageReference.getUid(); - Flag flag = messageReference.getFlag(); Timber.d("Setting referenced message (%d, %s) flag to %s", folderId, sourceMessageUid, flag); @@ -1511,10 +1511,11 @@ public class MessageCompose extends K9Activity implements OnClickListener, } /** - * When we are launched with an intent that includes a mailto: URI, we can actually gather quite a few of our - * message fields from it. + * When we are launched with an intent that includes a mailto: URI, we can actually + * gather quite a few of our message fields from it. * - * @param mailTo The MailTo object we use to initialize message field + * @param mailTo + * The MailTo object we use to initialize message field */ private void initializeFromMailto(MailTo mailTo) { recipientPresenter.initFromMailto(mailTo); @@ -1594,7 +1595,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, } else { currentMessageBuilder = null; new SendMessageTask(messagingController, preferences, account, contacts, message, - draftMessageId, plaintextSubject, relatedMessageReference).execute(); + draftMessageId, plaintextSubject, relatedMessageReference, relatedFlag).execute(); finish(); } } 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 5d5e51f81bfb5972e1644f3c71a8808b7b557597..d209c12170fc1e90e93902e3b6dc971f153c3bf5 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 @@ -1,5 +1,6 @@ package com.fsck.k9.activity +import android.accounts.AccountManager import android.annotation.SuppressLint import android.app.SearchManager import android.content.Context @@ -32,6 +33,10 @@ import com.fsck.k9.Preferences import com.fsck.k9.account.BackgroundAccountRemover import com.fsck.k9.activity.compose.MessageActions import com.fsck.k9.activity.setup.AccountSetupBasics +import com.fsck.k9.activity.setup.accountmanager.AccountManagerConstants.ACCOUNT_EMAIL_ADDRESS_KEY +import com.fsck.k9.activity.setup.accountmanager.AccountManagerConstants.EELO_ACCOUNT_TYPE +import com.fsck.k9.activity.setup.accountmanager.AccountManagerConstants.GOOGLE_ACCOUNT_TYPE +import com.fsck.k9.activity.setup.accountmanager.EeloAccountCreator import com.fsck.k9.controller.MessageReference import com.fsck.k9.fragment.MessageListFragment import com.fsck.k9.fragment.MessageListFragment.MessageListFragmentListener @@ -69,6 +74,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.component.KoinComponent import org.koin.core.component.inject import timber.log.Timber +import androidx.appcompat.widget.SearchView /** * MessageList is the primary user interface for the program. This Activity shows a list of messages. @@ -85,6 +91,9 @@ open class MessageList : private val recentChangesViewModel: RecentChangesViewModel by viewModel() + private lateinit var accountManager: AccountManager + private lateinit var searchView: SearchView + protected val searchStatusManager: SearchStatusManager by inject() private val preferences: Preferences by inject() private val channelUtils: NotificationChannelManager by inject() @@ -139,7 +148,15 @@ open class MessageList : finish() return } - val accounts = preferences.accounts + var accounts = preferences.accounts + + accountManager = AccountManager.get(this) + +// TODO remove old accounts automatically + if (addNewAccountsAutomatically(accounts)) { + accounts = preferences.accounts + } + deleteIncompleteAccounts(accounts) val hasAccountSetup = accounts.any { it.isFinishedSetup } if (!hasAccountSetup) { @@ -390,7 +407,6 @@ open class MessageList : noThreading = launchData.noThreading messageReference = launchData.messageReference - return true } @@ -406,7 +422,7 @@ open class MessageList : if (account.accountNumber.toString() == accountId) { val folderId = segmentList[1].toLong() val messageUid = segmentList[2] - val messageReference = MessageReference(account.uuid, folderId, messageUid, null) + val messageReference = MessageReference(account.uuid, folderId, messageUid) return LaunchData( search = messageReference.toLocalSearch(), @@ -850,10 +866,6 @@ open class MessageList : return super.onKeyUp(keyCode, event) } - override fun onSearchRequested(): Boolean { - return messageListFragment!!.onSearchRequested() - } - override fun onOptionsItemSelected(item: MenuItem): Boolean { val id = item.itemId if (id == android.R.id.home) { @@ -901,9 +913,6 @@ open class MessageList : } else if (id == R.id.select_all) { messageListFragment!!.selectAll() return true - } else if (id == R.id.search) { - messageListFragment!!.onSearchRequested() - return true } else if (id == R.id.search_remote) { messageListFragment!!.onRemoteSearch() return true @@ -999,6 +1008,25 @@ open class MessageList : override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.message_list_option, menu) this.menu = menu + + // setup search view + val searchItem = menu.findItem(R.id.search) + searchView = searchItem.actionView as SearchView + searchView.maxWidth = Int.MAX_VALUE + searchView.queryHint = resources.getString(R.string.search_action) + val searchManager = getSystemService(Context.SEARCH_SERVICE) as SearchManager + searchView.setSearchableInfo(searchManager.getSearchableInfo(componentName)) + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String): Boolean { + messageListFragment?.onSearchRequested(query) + return true + } + + override fun onQueryTextChange(s: String): Boolean { + return false + } + }) + return true } @@ -1165,7 +1193,6 @@ open class MessageList : } } - fun setActionBarTitle(title: String, subtitle: String? = null) { actionBar.title = title actionBar.subtitle = subtitle @@ -1267,19 +1294,24 @@ open class MessageList : } } - override fun startSearch(account: Account?, folderId: Long?): Boolean { + override fun startSearch(query: String, account: Account?, folderId: Long?): Boolean { // If this search was started from a MessageList of a single folder, pass along that folder info // so that we can enable remote search. - if (account != null && folderId != null) { - val appData = Bundle().apply { + val appData = if (account != null && folderId != null) { + Bundle().apply { putString(EXTRA_SEARCH_ACCOUNT, account.uuid) putLong(EXTRA_SEARCH_FOLDER, folderId) } - startSearch(null, false, appData, false) } else { // TODO Handle the case where we're searching from within a search result. - startSearch(null, false, null, false) + null } + val searchIntent = Intent(this, Search::class.java).apply { + action = Intent.ACTION_SEARCH + putExtra(SearchManager.QUERY, query) + putExtra(SearchManager.APP_DATA, appData) + } + startActivity(searchIntent) return true } @@ -1588,6 +1620,86 @@ open class MessageList : val noThreading: Boolean = false ) + private fun addNewAccountsAutomatically(accounts: List): Boolean { + return try { + val eeloAccounts: Array = getEeloAccountsOnDevice() + val googleAccounts: Array = getGoogleAccountsOnDevice() + var accountWasAdded = false + for (eeloAccount in eeloAccounts) { + val emailId: String = accountManager.getUserData(eeloAccount, ACCOUNT_EMAIL_ADDRESS_KEY) + if (!emailId.contains("@")) continue + var accountIsSignedIn = false + for (account in accounts) { + if (emailId == account.email) { + accountIsSignedIn = true + break + } + } + if (!accountIsSignedIn) { + val password: String = accountManager.getPassword(eeloAccount) + EeloAccountCreator.createAccount(this, emailId, password) + accountWasAdded = true + } + } +// for (googleAccount in googleAccounts) { +// val emailId: String = accountManager.getUserData( +// googleAccount, +// ACCOUNT_EMAIL_ADDRESS_KEY +// ) +// var accountIsSignedIn = false +// for (account in accounts) { +// if (emailId == account.email) { +// if (account.name == null) { // we need to fix an old bug +// account.name = emailId +// account.save(Preferences.getPreferences(this)) +// } +// accountIsSignedIn = true +// break +// } +// } +// if (!accountIsSignedIn) { +// GoogleAccountCreator.createAccount(this, emailId) +// accountWasAdded = true +// } +// } + for (googleAccount in googleAccounts) { + val emailId: String = accountManager.getUserData(googleAccount, ACCOUNT_EMAIL_ADDRESS_KEY) + var accountIsSignedIn = false + for (account in accounts) { + if (emailId == account.email) { + if (account.name == null) { // we need to fix an old bug + account.name = emailId + Preferences.getPreferences(this).saveAccount(account) + } + accountIsSignedIn = true + break + } + } + if (!accountIsSignedIn) { +// GoogleAccountCreator.createAccount(this, emailId) +// accountWasAdded = true +// } +// } + EeloAccountCreator.createAccount(this, emailId, "") + accountWasAdded = true + } + } + + accountWasAdded + } catch (e: SecurityException) { + e.printStackTrace() + false + } + } + + private fun getEeloAccountsOnDevice(): Array { + return accountManager.getAccountsByType(EELO_ACCOUNT_TYPE) + } + + private fun getGoogleAccountsOnDevice(): Array { + return accountManager.getAccountsByType(GOOGLE_ACCOUNT_TYPE) + } + companion object : KoinComponent { private const val EXTRA_SEARCH = "search_bytes" private const val EXTRA_NO_THREADING = "no_threading" diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientLoader.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientLoader.java index b75c011f9fe9820dfc97d89d6f6414abf22857f4..35c692cf06ead108d896ddb11452efe2c5e0113e 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientLoader.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientLoader.java @@ -110,8 +110,12 @@ public class RecipientLoader extends AsyncTaskLoader> { return timesContactedDiff; } - if (lhs.sortKey == null || rhs.sortKey == null) { + if (lhs.sortKey == null && rhs.sortKey == null) { return 0; + } else if (lhs.sortKey == null) { + return 1; + } else if (rhs.sortKey == null) { + return -1; } return CASE_INSENSITIVE_ORDER.compare(lhs.sortKey, rhs.sortKey); diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientMvpView.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientMvpView.kt index f88fc6f3f0313fd199c04778e5f2e2e51781bb72..60f5b84f62b902e206980874e5ccff7962e1b9fc 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientMvpView.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientMvpView.kt @@ -76,6 +76,7 @@ class RecipientMvpView(private val activity: MessageCompose) : View.OnFocusChang this.presenter = presenter toView.setTokenListener(object : RecipientSelectView.TokenListener { override fun onTokenAdded(recipient: Recipient) = presenter.onToTokenAdded() + override fun onTokenRemoved(recipient: Recipient) = presenter.onToTokenRemoved() override fun onTokenChanged(recipient: Recipient) = presenter.onToTokenChanged() @@ -130,7 +131,7 @@ class RecipientMvpView(private val activity: MessageCompose) : View.OnFocusChang } fun setCryptoProvider(openPgpProvider: String?) { -// TODO move "show advanced" into settings, or somewhere? + // TODO move "show advanced" into settings, or somewhere? toView.setCryptoProvider(openPgpProvider, false) ccView.setCryptoProvider(openPgpProvider, false) bccView.setCryptoProvider(openPgpProvider, false) @@ -190,6 +191,7 @@ class RecipientMvpView(private val activity: MessageCompose) : View.OnFocusChang fun silentlyRemoveBccAddresses(addresses: Array
) { if (addresses.isEmpty()) return + val addressesToRemove = addresses.toSet() for (recipient in bccRecipients.toList()) { removeAllTextChangedListeners(bccView) @@ -197,6 +199,7 @@ class RecipientMvpView(private val activity: MessageCompose) : View.OnFocusChang if (recipient.address in addressesToRemove) { bccView.removeObjectSync(recipient) } + addAllTextChangedListeners(bccView) } } @@ -214,7 +217,6 @@ class RecipientMvpView(private val activity: MessageCompose) : View.OnFocusChang fun setRecipientExpanderVisibility(visible: Boolean) { val childToDisplay = if (visible) VIEW_INDEX_BCC_EXPANDER_VISIBLE else VIEW_INDEX_BCC_EXPANDER_HIDDEN - if (recipientExpanderContainer.displayedChild != childToDisplay) { recipientExpanderContainer.displayedChild = childToDisplay } @@ -263,7 +265,6 @@ class RecipientMvpView(private val activity: MessageCompose) : View.OnFocusChang fun showCryptoSpecialMode(cryptoSpecialModeDisplayType: CryptoSpecialModeDisplayType) { val shouldBeHidden = cryptoSpecialModeDisplayType.childIdToDisplay == VIEW_INDEX_HIDDEN if (shouldBeHidden) { - cryptoSpecialModeIndicator.isGone = true return } @@ -286,6 +287,7 @@ class RecipientMvpView(private val activity: MessageCompose) : View.OnFocusChang return } + cryptoStatusView.isVisible = true cryptoStatusView.displayedChildId = cryptoStatusDisplayType.childIdToDisplay cryptoStatusView.animate() @@ -383,6 +385,9 @@ class RecipientMvpView(private val activity: MessageCompose) : View.OnFocusChang UNCONFIGURED(VIEW_INDEX_HIDDEN), UNINITIALIZED(VIEW_INDEX_HIDDEN), SIGN_ONLY(R.id.crypto_status_disabled), + UNAVAILABLE(VIEW_INDEX_HIDDEN), + ENABLED(R.id.crypto_status_enabled), + ENABLED_ERROR(R.id.crypto_status_error), ENABLED_TRUSTED(R.id.crypto_status_trusted), AVAILABLE(R.id.crypto_status_disabled), ERROR(R.id.crypto_status_error); diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientPresenter.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientPresenter.kt index 7cffd00c61f7243c7d75b88efa7ac02aaa409c29..b849cf7beb35c3702f2bd02bc4a68d6fb820d5e4 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientPresenter.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientPresenter.kt @@ -112,6 +112,7 @@ class RecipientPresenter( recipientMvpView.showCcUncompletedError() return true } + if (recipientMvpView.recipientBccHasUncompletedText()) { recipientMvpView.showBccUncompletedError() return true @@ -131,8 +132,10 @@ class RecipientPresenter( } else { replyToParser.getRecipientsToReplyTo(message, account) } + addToAddresses(*replyToAddresses.to) addCcAddresses(*replyToAddresses.cc) + val shouldSendAsPgpInline = composePgpInlineDecider.shouldReplyInline(message) if (shouldSendAsPgpInline) { isForceTextMessageFormat = true @@ -190,6 +193,7 @@ class RecipientPresenter( fun initFromDraftMessage(message: Message) { initRecipientsFromDraftMessage(message) + val draftStateHeader = message.getHeader(AutocryptDraftStateHeader.AUTOCRYPT_DRAFT_STATE_HEADER) if (draftStateHeader.size == 1) { initEncryptionStateFromDraftStateHeader(draftStateHeader.first()) @@ -256,7 +260,6 @@ class RecipientPresenter( this.alwaysBccAddresses = alwaysBccAddresses if (alwaysBccAddresses.isEmpty()) return - object : RecipientLoader(context, account.openPgpProvider, *alwaysBccAddresses) { override fun deliverResult(result: List?) { val recipientArray = result!!.toTypedArray() @@ -321,7 +324,7 @@ class RecipientPresenter( } fun onSwitchIdentity(identity: Identity) { -// TODO decide what actually to do on identity switch? + // TODO decide what actually to do on identity switch? asyncUpdateCryptoStatus() } @@ -357,6 +360,7 @@ class RecipientPresenter( lastFocusedType = RecipientType.TO } } + updateRecipientExpanderVisibility() } @@ -367,6 +371,7 @@ class RecipientPresenter( fun asyncUpdateCryptoStatus() { currentCachedCryptoStatus = null + val openPgpProviderState = openPgpApiManager.openPgpProviderState var accountCryptoKey: Long? = account.openPgpKey if (accountCryptoKey == Account.NO_OPENPGP_KEY) { @@ -404,6 +409,7 @@ class RecipientPresenter( } else { composeCryptoStatus } + redrawCachedCryptoStatusIcon() } }.execute() @@ -411,6 +417,7 @@ class RecipientPresenter( private fun redrawCachedCryptoStatusIcon() { val cryptoStatus = checkNotNull(currentCachedCryptoStatus) { "must have cached crypto status to redraw it!" } + recipientMvpView.setRecipientTokensShowCryptoEnabled(cryptoStatus.isEncryptionEnabled) recipientMvpView.showCryptoStatus(cryptoStatus.displayType) recipientMvpView.showCryptoSpecialMode(cryptoStatus.specialModeDisplayType) @@ -477,14 +484,15 @@ class RecipientPresenter( private fun addRecipientFromContactUri(recipientType: RecipientType, uri: Uri?) { object : RecipientLoader(context, account.openPgpProvider, uri, false) { override fun deliverResult(result: List?) { -// TODO handle multiple available mail addresses for a contact? - + // TODO handle multiple available mail addresses for a contact? if (result!!.isEmpty()) { recipientMvpView.showErrorContactNoAddress() return } + val recipient = result[0] recipientMvpView.addRecipients(recipientType, recipient) + stopLoading() abandon() } @@ -512,6 +520,7 @@ class RecipientPresenter( when (requestCode) { CONTACT_PICKER_TO, CONTACT_PICKER_CC, CONTACT_PICKER_BCC -> { if (resultCode != Activity.RESULT_OK || data == null) return + val recipientType = requestCode.toRecipientType() addRecipientFromContactUri(recipientType, data.data) } @@ -519,7 +528,6 @@ class RecipientPresenter( OPENPGP_USER_INTERACTION -> { openPgpApiManager.onUserInteractionResult() } - REQUEST_CODE_AUTOCRYPT -> { asyncUpdateCryptoStatus() } @@ -541,8 +549,7 @@ class RecipientPresenter( toggleEncryptionState(false) } OpenPgpProviderState.UI_REQUIRED -> { -// TODO show openpgp settings - + // TODO show openpgp settings val pendingIntent = openPgpApiManager.userInteractionPendingIntent recipientMvpView.launchUserInteractionPendingIntent(pendingIntent, OPENPGP_USER_INTERACTION) } @@ -564,7 +571,6 @@ class RecipientPresenter( return } - if (currentCryptoMode == CryptoMode.SIGN_ONLY) { recipientMvpView.showErrorIsSignOnly() return @@ -584,7 +590,6 @@ class RecipientPresenter( } else { onCryptoModeChanged(CryptoMode.CHOICE_ENABLED) if (showGotIt) { - recipientMvpView.showOpenPgpEncryptExplanationDialog(); recipientMvpView.showOpenPgpEncryptExplanationDialog() } } @@ -634,6 +639,7 @@ class RecipientPresenter( require(messageBuilder !is PgpMessageBuilder) { "PpgMessageBuilder must be called with ComposeCryptoStatus argument!" } + messageBuilder.setTo(toAddresses) messageBuilder.setCc(ccAddresses) messageBuilder.setBcc(bccAddresses) @@ -692,8 +698,6 @@ class RecipientPresenter( return true } - - return false } @@ -704,6 +708,7 @@ class RecipientPresenter( K9.saveSettingsAsync() return true } + return false } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToPresenter.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToPresenter.kt index 63099f0995b908c6b244415a1e8ca3461b3459b4..c8f33e0075ae1ce036a3c9d5a73eba0e866cd3a7 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToPresenter.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToPresenter.kt @@ -5,11 +5,9 @@ import com.fsck.k9.Identity import com.fsck.k9.mail.Address import com.fsck.k9.mail.Message - private const val STATE_KEY_REPLY_TO_SHOWN = "com.fsck.k9.activity.compose.ReplyToPresenter.replyToShown" class ReplyToPresenter(private val view: ReplyToView) { - private lateinit var identity: Identity private var identityReplyTo: Array
? = null @@ -20,7 +18,6 @@ class ReplyToPresenter(private val view: ReplyToView) { } } - fun getAddresses(): Array
{ return view.getAddresses() } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToView.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToView.kt index 2ddb9402d4f63c0582e01dd65fac48e035dacd52..76297080e585b7aef131c9abd7cf1e393a4285b3 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToView.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToView.kt @@ -122,4 +122,4 @@ class ReplyToView(activity: MessageCompose) : View.OnClickListener { replyToView.addTextChangedListener(textWatcher) } } -} \ No newline at end of file +} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupBasics.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupBasics.java index abf985c11bcf01785911862c0d4d23f2492b3f2a..ee56958a5aa58be0cd7ad0c2b14a7834d77ceee1 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupBasics.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupBasics.java @@ -33,6 +33,7 @@ import com.fsck.k9.mail.ServerSettings; import com.fsck.k9.mailstore.SpecialLocalFoldersCreator; import com.fsck.k9.ui.R; import com.fsck.k9.ui.ConnectionSettings; +import com.fsck.k9.ui.settings.ExtraAccountDiscovery; import com.fsck.k9.view.ClientCertificateSpinner; import com.fsck.k9.view.ClientCertificateSpinner.OnClientCertificateChangedListener; import com.google.android.material.textfield.TextInputEditText; @@ -301,6 +302,12 @@ public class AccountSetupBasics extends K9Activity String email = mEmailView.getText().toString(); + ConnectionSettings extraConnectionSettings = ExtraAccountDiscovery.discover(email); + if (extraConnectionSettings != null) { + finishAutoSetup(extraConnectionSettings); + return; + } + ConnectionSettings connectionSettings = providersXmlDiscoveryDiscover(email, DiscoveryTarget.INCOMING_AND_OUTGOING); if (connectionSettings != null) { finishAutoSetup(connectionSettings); diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupCheckSettings.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupCheckSettings.java index a58531c76a6ba188b0fc147179351f0146d9c713..00522035b5c10df7efe4975b143eaf0a363f063e 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupCheckSettings.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupCheckSettings.java @@ -45,6 +45,7 @@ import com.fsck.k9.fragment.ConfirmationDialogFragment.ConfirmationDialogFragmen import com.fsck.k9.mail.AuthenticationFailedException; import com.fsck.k9.mail.CertificateValidationException; import com.fsck.k9.mail.MailServerDirection; + import com.fsck.k9.mail.MessagingException; import com.fsck.k9.mail.filter.Hex; import com.fsck.k9.preferences.Protocols; @@ -213,33 +214,33 @@ public class AccountSetupCheckSettings extends K9Activity implements OnClickList Object value = subjectAlternativeName.get(1); String name; switch (type) { - case 0: - Timber.w("SubjectAltName of type OtherName not supported."); - continue; - case 1: // RFC822Name - name = (String)value; - break; - case 2: // DNSName - name = (String)value; - break; - case 3: - Timber.w("unsupported SubjectAltName of type x400Address"); - continue; - case 4: - Timber.w("unsupported SubjectAltName of type directoryName"); - continue; - case 5: - Timber.w("unsupported SubjectAltName of type ediPartyName"); - continue; - case 6: // Uri - name = (String)value; - break; - case 7: // ip-address - name = (String)value; - break; - default: - Timber.w("unsupported SubjectAltName of unknown type"); - continue; + case 0: + Timber.w("SubjectAltName of type OtherName not supported."); + continue; + case 1: // RFC822Name + name = (String)value; + break; + case 2: // DNSName + name = (String)value; + break; + case 3: + Timber.w("unsupported SubjectAltName of type x400Address"); + continue; + case 4: + Timber.w("unsupported SubjectAltName of type directoryName"); + continue; + case 5: + Timber.w("unsupported SubjectAltName of type ediPartyName"); + continue; + case 6: // Uri + name = (String)value; + break; + case 7: // ip-address + name = (String)value; + break; + default: + Timber.w("unsupported SubjectAltName of unknown type"); + continue; } // if some of the SubjectAltNames match the store or transport -host, @@ -248,8 +249,8 @@ public class AccountSetupCheckSettings extends K9Activity implements OnClickList //TODO: localize this string altNamesText.append("Subject(alt): ").append(name).append(",...\n"); } else if (name.startsWith("*.") && ( - incomingServerHost.endsWith(name.substring(2)) || - outgoingServerHost.endsWith(name.substring(2)))) { + incomingServerHost.endsWith(name.substring(2)) || + outgoingServerHost.endsWith(name.substring(2)))) { //TODO: localize this string altNamesText.append("Subject(alt): ").append(name).append(",...\n"); } @@ -287,27 +288,27 @@ public class AccountSetupCheckSettings extends K9Activity implements OnClickList // TODO: refactor with DialogFragment. // This is difficult because we need to pass through chain[0] for onClick() new AlertDialog.Builder(AccountSetupCheckSettings.this) - .setTitle(getString(R.string.account_setup_failed_dlg_invalid_certificate_title)) - //.setMessage(getString(R.string.account_setup_failed_dlg_invalid_certificate) - .setMessage(getString(msgResId, exMessage) - + " " + chainInfo.toString() - ) - .setCancelable(true) - .setPositiveButton( - getString(R.string.account_setup_failed_dlg_invalid_certificate_accept), - new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - acceptCertificate(chain[0]); - } - }) - .setNegativeButton( - getString(R.string.account_setup_failed_dlg_invalid_certificate_reject), - new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - finish(); - } - }) - .show(); + .setTitle(getString(R.string.account_setup_failed_dlg_invalid_certificate_title)) + //.setMessage(getString(R.string.account_setup_failed_dlg_invalid_certificate) + .setMessage(getString(msgResId, exMessage) + + " " + chainInfo.toString() + ) + .setCancelable(true) + .setPositiveButton( + getString(R.string.account_setup_failed_dlg_invalid_certificate_accept), + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + acceptCertificate(chain[0]); + } + }) + .setNegativeButton( + getString(R.string.account_setup_failed_dlg_invalid_certificate_reject), + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + finish(); + } + }) + .show(); } }); } @@ -315,7 +316,7 @@ public class AccountSetupCheckSettings extends K9Activity implements OnClickList /** * Permanently accepts a certificate for the INCOMING or OUTGOING direction * by adding it to the local key store. - * + * * @param certificate */ private void acceptCertificate(X509Certificate certificate) { diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupIncoming.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupIncoming.java index 6299df3af64833543a6fe6ea05c7caa350392d72..b2c1323a5fd8a989996af0fc40d669354edc5e70 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupIncoming.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupIncoming.java @@ -22,7 +22,6 @@ import android.widget.Spinner; import android.widget.Toast; import com.fsck.k9.Account; -import com.fsck.k9.Account.FolderMode; import com.fsck.k9.DI; import com.fsck.k9.LocalKeyStoreManager; import com.fsck.k9.Preferences; @@ -181,6 +180,15 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener } boolean editSettings = Intent.ACTION_EDIT.equals(getIntent().getAction()); + if (editSettings) { + TextInputLayoutHelper.configureAuthenticatedPasswordToggle( + mPasswordLayoutView, + this, + getString(R.string.account_setup_basics_show_password_biometrics_title), + getString(R.string.account_setup_basics_show_password_biometrics_subtitle), + getString(R.string.account_setup_basics_show_password_need_lock) + ); + } if (editSettings) { TextInputLayoutHelper.configureAuthenticatedPasswordToggle( diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupOutgoing.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupOutgoing.java index a183cacc5b2ee44d4865eb288623b6e68f58c32f..56ad5cdf2dbfb84998fc75e481b9af5e27faff36 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupOutgoing.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupOutgoing.java @@ -1,3 +1,4 @@ + package com.fsck.k9.activity.setup; @@ -148,7 +149,6 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, } boolean editSettings = Intent.ACTION_EDIT.equals(getIntent().getAction()); - if (editSettings) { TextInputLayoutHelper.configureAuthenticatedPasswordToggle( mPasswordLayoutView, diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/accountmanager/AccountManagerConstants.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/accountmanager/AccountManagerConstants.kt new file mode 100644 index 0000000000000000000000000000000000000000..8e3c3065fdd549deed67e32c45543ce361437881 --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/accountmanager/AccountManagerConstants.kt @@ -0,0 +1,8 @@ +package com.fsck.k9.activity.setup.accountmanager + +object AccountManagerConstants { + const val EELO_ACCOUNT_TYPE = "e.foundation.webdav.eelo" + const val GOOGLE_ACCOUNT_TYPE = "foundation.e.accountmanager.google" + const val ACCOUNT_EMAIL_ADDRESS_KEY = "email_address" + const val MAIL_CONTENT_AUTHORITY = "foundation.e.mail.provider.AppContentProvider" +} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/accountmanager/EeloAccountCreator.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/accountmanager/EeloAccountCreator.java new file mode 100644 index 0000000000000000000000000000000000000000..223884b5edc1a423b3826386b08299cdc42e9532 --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/accountmanager/EeloAccountCreator.java @@ -0,0 +1,90 @@ +package com.fsck.k9.activity.setup.accountmanager; + + +import android.content.Context; + +import com.fsck.k9.Account; +import com.fsck.k9.Account.DeletePolicy; +import com.fsck.k9.Core; +import com.fsck.k9.DI; +import com.fsck.k9.Preferences; +import com.fsck.k9.account.AccountCreator; +import com.fsck.k9.autodiscovery.api.DiscoveredServerSettings; +import com.fsck.k9.autodiscovery.api.DiscoveryResults; +import com.fsck.k9.autodiscovery.api.DiscoveryTarget; +import com.fsck.k9.autodiscovery.providersxml.ProvidersXmlDiscovery; +import com.fsck.k9.mail.ServerSettings; +import com.fsck.k9.ui.ConnectionSettings; +import com.fsck.k9.mailstore.SpecialLocalFoldersCreator; +import timber.log.Timber; + + +public class EeloAccountCreator { + private static final ProvidersXmlDiscovery providersXmlDiscovery = DI.get(ProvidersXmlDiscovery.class); + private static final AccountCreator accountCreator = DI.get(AccountCreator.class); + private static final SpecialLocalFoldersCreator localFoldersCreator = DI.get(SpecialLocalFoldersCreator.class); + + public static void createAccount(Context context, String emailId, String password) { + Preferences preferences = Preferences.getPreferences(context); + + Account account = preferences.newAccount(); + account.setChipColor(accountCreator.pickColor()); + account.setEmail(emailId); + account.setDescription(emailId); + + ConnectionSettings connectionSettings = providersXmlDiscoveryDiscover(emailId); + if (connectionSettings == null) { + Timber.e("Error while trying to initialise account configuration."); + return; + } + ServerSettings incomingSettings = connectionSettings.getIncoming().newPassword(password); + account.setIncomingServerSettings(incomingSettings); + ServerSettings outgoingSettings = connectionSettings.getOutgoing().newPassword(password); + account.setOutgoingServerSettings(outgoingSettings); + + DeletePolicy deletePolicy = accountCreator.getDefaultDeletePolicy(incomingSettings.type); + account.setDeletePolicy(deletePolicy); + + localFoldersCreator.createSpecialLocalFolders(account); + account.markSetupFinished(); + + preferences.saveAccount(account); + Core.setServicesEnabled(context); + } + + + + private static ConnectionSettings providersXmlDiscoveryDiscover(String email) { + DiscoveryResults discoveryResults = + providersXmlDiscovery.discover(email, DiscoveryTarget.INCOMING_AND_OUTGOING); + if (discoveryResults == null || + (discoveryResults.getIncoming().size() < 1 || discoveryResults.getOutgoing().size() < 1)) { + return null; + } + DiscoveredServerSettings incoming = discoveryResults.getIncoming().get(0); + DiscoveredServerSettings outgoing = discoveryResults.getOutgoing().get(0); + return new ConnectionSettings( + new ServerSettings( + incoming.getProtocol(), + incoming.getHost(), + incoming.getPort(), + incoming.getSecurity(), + incoming.getAuthType(), + incoming.getUsername(), + null, + null + ), + new ServerSettings( + outgoing.getProtocol(), + outgoing.getHost(), + outgoing.getPort(), + outgoing.getSecurity(), + outgoing.getAuthType(), + outgoing.getUsername(), + null, + null + ) + ); + } +} + diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/contacts/ContactLetterExtractor.kt b/app/ui/legacy/src/main/java/com/fsck/k9/contacts/ContactLetterExtractor.kt index 3d6e29b44bcfe6b87741f48f684024f08dcdb7e3..3b387a678ac622f0008d215bab205d3de6547805 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/contacts/ContactLetterExtractor.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/contacts/ContactLetterExtractor.kt @@ -1,7 +1,6 @@ package com.fsck.k9.contacts import com.fsck.k9.mail.Address -import java.util.Locale class ContactLetterExtractor { fun extractContactLetter(address: Address): String { diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListFragment.kt index b745e67d9192ed7d5c551e17b7c059db0839bc4e..04e7e0b7e1f62dd3a8a96414e506b39d2463734d 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListFragment.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListFragment.kt @@ -70,6 +70,7 @@ class MessageListFragment : private val folderNameFormatter: FolderNameFormatter by lazy { folderNameFormatterFactory.create(requireContext()) } private val messagingController: MessagingController by inject() private val preferences: Preferences by inject() + private val clock: Clock by inject() private val handler = MessageListHandler(this) private val activityListener = MessageListActivityListener() @@ -252,7 +253,7 @@ class MessageListFragment : contactsPictureLoader = ContactPicture.getContactPictureLoader(), listItemListener = this, appearance = messageListAppearance, - relativeDateTimeFormatter = RelativeDateTimeFormatter(requireContext(), Clock.INSTANCE) + relativeDateTimeFormatter = RelativeDateTimeFormatter(requireContext(), clock) ) adapter.activeMessage = activeMessage @@ -985,7 +986,14 @@ class MessageListFragment : else -> null } - displayFolderChoice(ACTIVITY_CHOOSE_FOLDER_MOVE, folderId, messages.first().accountUuid, null, messages) + displayFolderChoice( + operation = FolderOperation.MOVE, + requestCode = ACTIVITY_CHOOSE_FOLDER_MOVE, + sourceFolderId = folderId, + accountUuid = messages.first().accountUuid, + lastSelectedFolderId = null, + messages = messages + ) } private fun onCopy(message: MessageReference) { @@ -1001,18 +1009,31 @@ class MessageListFragment : else -> null } - displayFolderChoice(ACTIVITY_CHOOSE_FOLDER_COPY, folderId, messages.first().accountUuid, null, messages) + displayFolderChoice( + operation = FolderOperation.COPY, + requestCode = ACTIVITY_CHOOSE_FOLDER_COPY, + sourceFolderId = folderId, + accountUuid = messages.first().accountUuid, + lastSelectedFolderId = null, + messages = messages + ) } private fun displayFolderChoice( + operation: FolderOperation, requestCode: Int, sourceFolderId: Long?, accountUuid: String, lastSelectedFolderId: Long?, messages: List ) { + val action = when (operation) { + FolderOperation.COPY -> ChooseFolderActivity.Action.COPY + FolderOperation.MOVE -> ChooseFolderActivity.Action.MOVE + } val intent = ChooseFolderActivity.buildLaunchIntent( context = requireContext(), + action = action, accountUuid = accountUuid, currentFolderId = sourceFolderId, scrollToFolderId = lastSelectedFolderId, @@ -1254,7 +1275,7 @@ class MessageListFragment : private fun getReferenceForPosition(position: Int): MessageReference { val item = adapter.getItem(position) - return MessageReference(item.account.uuid, item.folderId, item.messageUid, null) + return MessageReference(item.account.uuid, item.folderId, item.messageUid) } private fun openMessageAtPosition(position: Int) { @@ -1309,7 +1330,7 @@ class MessageListFragment : return adapter.messages .asSequence() .filter { it.uniqueId in selected } - .map { MessageReference(it.account.uuid, it.folderId, it.messageUid, null) } + .map { MessageReference(it.account.uuid, it.folderId, it.messageUid) } .toList() } @@ -1413,9 +1434,9 @@ class MessageListFragment : val isRemoteSearchAllowed: Boolean get() = isManualSearch && !isRemoteSearch && isSingleFolderMode && account?.isAllowRemoteSearch == true - fun onSearchRequested(): Boolean { + fun onSearchRequested(query: String): Boolean { val folderId = currentFolder?.databaseId - return fragmentListener.startSearch(account, folderId) + return fragmentListener.startSearch(query, account, folderId) } fun setMessageList(messageListInfo: MessageListInfo) { @@ -1851,12 +1872,13 @@ class MessageListFragment : R.id.unflag -> setFlagForSelected(Flag.FLAGGED, false) R.id.select_all -> selectAll() R.id.archive -> { - // only if the account supports this onArchive(checkedMessages) + // TODO: Only finish action mode if all messages have been moved. selectedCount = 0 } R.id.spam -> { onSpam(checkedMessages) + // TODO: Only finish action mode if all messages have been moved. selectedCount = 0 } R.id.move -> { @@ -1896,7 +1918,7 @@ class MessageListFragment : fun openMessage(messageReference: MessageReference) fun setMessageListTitle(title: String, subtitle: String?) fun onCompose(account: Account?) - fun startSearch(account: Account?, folderId: Long?): Boolean + fun startSearch(query: String, account: Account?, folderId: Long?): Boolean fun remoteSearchStarted() fun goBack() fun updateMenu() diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/FlowExtensions.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/FlowExtensions.kt new file mode 100644 index 0000000000000000000000000000000000000000..61689a8e5de118020667f655eae0e9ad4a33025e --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/FlowExtensions.kt @@ -0,0 +1,17 @@ +package com.fsck.k9.ui + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch + +fun Flow.observe(lifecycleOwner: LifecycleOwner, action: suspend (T) -> Unit) { + lifecycleOwner.lifecycleScope.launch { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + collect(action) + } + } +} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/K9Drawer.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/K9Drawer.kt index 631035cbf60b6d2c73bb0eef843645d092ca6908..6974dc861e8b163090cb4ae3606cda3684f46e3c 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/K9Drawer.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/K9Drawer.kt @@ -28,7 +28,9 @@ import com.fsck.k9.ui.account.AccountsViewModel import com.fsck.k9.ui.account.DisplayAccount import com.fsck.k9.ui.base.Theme import com.fsck.k9.ui.base.ThemeManager +import com.fsck.k9.ui.folders.DisplayUnifiedInbox import com.fsck.k9.ui.folders.FolderIconProvider +import com.fsck.k9.ui.folders.FolderList import com.fsck.k9.ui.folders.FolderNameFormatter import com.fsck.k9.ui.folders.FoldersViewModel import com.fsck.k9.ui.settings.SettingsActivity @@ -56,8 +58,6 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.koin.core.parameter.parametersOf -import com.fsck.k9.ui.folders.FolderList -import com.fsck.k9.ui.folders.DisplayUnifiedInbox private const val UNREAD_SYMBOL = "\u2B24" private const val STARRED_SYMBOL = "\u2605" @@ -178,21 +178,18 @@ class K9Drawer(private val parent: MessageList, savedInstanceState: Bundle?) : K } } - /** - * Format the unread and starred counts for display in the fragment Badge box - */ private fun buildBadgeText(displayAccount: DisplayAccount): String? { return buildBadgeText(displayAccount.unreadMessageCount, displayAccount.starredMessageCount) } - private fun buildBadgeText(unifiedInbox: DisplayUnifiedInbox): String? { - return buildBadgeText(unifiedInbox.unreadMessageCount, unifiedInbox.starredMessageCount) - } - private fun buildBadgeText(displayFolder: DisplayFolder): String? { return buildBadgeText(displayFolder.unreadMessageCount, displayFolder.starredMessageCount) } + private fun buildBadgeText(unifiedInbox: DisplayUnifiedInbox): String? { + return buildBadgeText(unifiedInbox.unreadMessageCount, unifiedInbox.starredMessageCount) + } + private fun buildBadgeText(unreadCount: Int, starredCount: Int): String? { return if (K9.isShowStarredCount) { buildBadgeTextWithStarredCount(unreadCount, starredCount) @@ -263,7 +260,6 @@ class K9Drawer(private val parent: MessageList, savedInstanceState: Bundle?) : K var newActiveProfile: IProfile? = null val accountItems = displayAccounts.map { displayAccount -> val account = displayAccount.account - val drawerId = (account.accountNumber + 1 shl DRAWER_ACCOUNT_SHIFT).toLong() val drawerColors = getDrawerColorsForAccount(account) val selectedTextColor = drawerColors.accentColor.toSelectedColorStateList() @@ -272,7 +268,7 @@ class K9Drawer(private val parent: MessageList, savedInstanceState: Bundle?) : K isNameShown = true nameText = account.description ?: "" descriptionText = account.email - identifier = drawerId + identifier = account.drawerId tag = account textColor = selectedTextColor descriptionTextColor = selectedTextColor @@ -314,11 +310,10 @@ class K9Drawer(private val parent: MessageList, savedInstanceState: Bundle?) : K fun updateUserAccountsAndFolders(account: Account?) { if (account != null) { initializeWithAccountColor(account) - headerView.setActiveProfile((account.accountNumber + 1 shl DRAWER_ACCOUNT_SHIFT).toLong()) + headerView.setActiveProfile(account.drawerId) foldersViewModel.loadFolders(account) } - // Account can be null to refresh all (unified inbox or account list). swipeRefreshLayout.setOnRefreshListener { refreshAndShowProgress(if (headerView.selectionListShown) null else account) } @@ -385,7 +380,6 @@ class K9Drawer(private val parent: MessageList, savedInstanceState: Bundle?) : K badgeText = text badgeStyle = folderBadgeStyle } - } sliderView.addItems(unifiedInboxItem) @@ -396,9 +390,10 @@ class K9Drawer(private val parent: MessageList, savedInstanceState: Bundle?) : K } } + val accountOffset = folderList.accountId.toLong() shl DRAWER_ACCOUNT_SHIFT for (displayFolder in folderList.folders) { val folder = displayFolder.folder - val drawerId = folder.id shl DRAWER_FOLDER_SHIFT + val drawerId = accountOffset + folder.id val drawerItem = FolderDrawerItem().apply { iconRes = folderIconProvider.getFolderIcon(folder.type) @@ -409,7 +404,6 @@ class K9Drawer(private val parent: MessageList, savedInstanceState: Bundle?) : K badgeText = text badgeStyle = folderBadgeStyle } - selectedColorInt = selectedBackgroundColor textColor = selectedTextColor } @@ -530,10 +524,12 @@ class K9Drawer(private val parent: MessageList, savedInstanceState: Bundle?) : K private val IProfile.accountUuid: String? get() = (this.tag as? Account)?.uuid + private val Account.drawerId: Long + get() = (accountNumber + 1).toLong() + companion object { - // Bit shift for identifiers of user folders items, to leave space for other items - private const val DRAWER_FOLDER_SHIFT: Int = 20 - private const val DRAWER_ACCOUNT_SHIFT: Int = 3 + // Use the lower 48 bits for the folder ID, the upper bits for the account's drawer ID + private const val DRAWER_ACCOUNT_SHIFT: Int = 48 private const val DRAWER_ID_UNIFIED_INBOX: Long = 0 private const val DRAWER_ID_DIVIDER: Long = 1 diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/AccountImageLoader.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/AccountImageLoader.kt index 5097a21bad88610ee7e1b34aa572a9f5cc726aef..c13617b47d99fe6f9b2433829b8555590f926fcc 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/AccountImageLoader.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/AccountImageLoader.kt @@ -1,10 +1,10 @@ package com.fsck.k9.ui.account -import android.app.Activity import android.content.Context import android.widget.ImageView import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.fsck.k9.ui.helper.findActivity /** * Load the account image into an [ImageView]. @@ -28,7 +28,7 @@ class AccountImageLoader(private val accountFallbackImageProvider: AccountFallba } private inline fun Context.ifNotDestroyed(block: (Context) -> Unit) { - if ((this as? Activity)?.isDestroyed == true) { + if (findActivity()?.isDestroyed == true) { // Do nothing because Glide would throw an exception } else { block(this) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/AccountsLiveData.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/AccountsLiveData.kt deleted file mode 100644 index 0346fe52dfbca5ac2ac7e13d9a0d56fc0e6cc2dd..0000000000000000000000000000000000000000 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/AccountsLiveData.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.fsck.k9.ui.account - -import androidx.lifecycle.LiveData -import com.fsck.k9.Account -import com.fsck.k9.AccountsChangeListener -import com.fsck.k9.Preferences -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class AccountsLiveData(val preferences: Preferences) : LiveData>(), AccountsChangeListener { - - private fun loadAccountsAsync() { - GlobalScope.launch(Dispatchers.Main) { - value = withContext(Dispatchers.IO) { - loadAccounts() - } - } - } - - override fun onAccountsChanged() { - loadAccountsAsync() - } - - private fun loadAccounts(): List { - return preferences.accounts - } - - override fun onActive() { - super.onActive() - preferences.addOnAccountsChangeListener(this) - loadAccountsAsync() - } - - override fun onInactive() { - super.onInactive() - preferences.removeOnAccountsChangeListener(this) - } -} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/DisplayAccount.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/DisplayAccount.kt index 982a5af3f860ffab071ea386470cbc6a5b8491a6..62de252f80b42c5cd02d61e6e5a8eca3ea958d10 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/DisplayAccount.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/DisplayAccount.kt @@ -7,4 +7,4 @@ data class DisplayAccount( val account: Account, val unreadMessageCount: Int, val starredMessageCount: Int -) \ No newline at end of file +) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/choosefolder/ChooseFolderActivity.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/choosefolder/ChooseFolderActivity.kt index b351d7bad409691b45c3a5231f2f28722a8f70a6..e6e77e3f0f74f3b73fb08533e1c3712f5c568508 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/choosefolder/ChooseFolderActivity.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/choosefolder/ChooseFolderActivity.kt @@ -36,6 +36,7 @@ class ChooseFolderActivity : K9Activity() { private lateinit var recyclerView: RecyclerView private lateinit var itemAdapter: ItemAdapter private lateinit var account: Account + private lateinit var action: Action private var currentFolderId: Long? = null private var scrollToFolderId: Long? = null private var messageReference: String? = null @@ -44,13 +45,19 @@ class ChooseFolderActivity : K9Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setLayout(R.layout.folder_list) - setTitle(R.string.choose_folder_title) if (!decodeArguments(savedInstanceState)) { finish() return } + when (action) { + Action.MOVE -> setTitle(R.string.choose_folder_move_title) + Action.COPY -> setTitle(R.string.choose_folder_copy_title) + else -> setTitle(R.string.choose_folder_title) + } + + initializeActionBar() initializeFolderList() viewModel.getFolders().observe(this) { folders -> @@ -64,6 +71,8 @@ class ChooseFolderActivity : K9Activity() { } private fun decodeArguments(savedInstanceState: Bundle?): Boolean { + action = intent.action?.toAction() ?: error("Missing Intent action") + val accountUuid = intent.getStringExtra(EXTRA_ACCOUNT) ?: return false account = preferences.getAccount(accountUuid) ?: return false @@ -84,6 +93,12 @@ class ChooseFolderActivity : K9Activity() { return if (showDisplayableOnly) account.folderDisplayMode else account.folderTargetMode } + private fun initializeActionBar() { + val actionBar = supportActionBar ?: error("Action bar missing") + actionBar.setDisplayHomeAsUpEnabled(true) + actionBar.setHomeAsUpIndicator(R.drawable.ic_close) + } + private fun initializeFolderList() { itemAdapter = ItemAdapter() itemAdapter.itemFilter.filterPredicate = ::folderListFilter @@ -161,6 +176,7 @@ class ChooseFolderActivity : K9Activity() { override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { + android.R.id.home -> finish() R.id.display_1st_class -> setDisplayMode(FolderMode.FIRST_CLASS) R.id.display_1st_and_2nd_class -> setDisplayMode(FolderMode.FIRST_AND_SECOND_CLASS) R.id.display_not_second_class -> setDisplayMode(FolderMode.NOT_SECOND_CLASS) @@ -211,6 +227,14 @@ class ChooseFolderActivity : K9Activity() { return if (containsKey(name)) getLong(name) else null } + private fun String.toAction() = Action.valueOf(this) + + enum class Action { + MOVE, + COPY, + CHOOSE + } + companion object { private const val STATE_SCROLL_TO_FOLDER_ID = "scrollToFolderId" private const val STATE_DISPLAY_MODE = "displayMode" @@ -226,6 +250,7 @@ class ChooseFolderActivity : K9Activity() { @JvmStatic fun buildLaunchIntent( context: Context, + action: Action, accountUuid: String, currentFolderId: Long? = null, scrollToFolderId: Long? = null, @@ -233,6 +258,7 @@ class ChooseFolderActivity : K9Activity() { messageReference: MessageReference? = null ): Intent { return Intent(context, ChooseFolderActivity::class.java).apply { + this.action = action.toString() putExtra(EXTRA_ACCOUNT, accountUuid) currentFolderId?.let { putExtra(EXTRA_CURRENT_FOLDER_ID, currentFolderId) } scrollToFolderId?.let { putExtra(EXTRA_SCROLL_TO_FOLDER_ID, scrollToFolderId) } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/choosefolder/ChooseFolderViewModel.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/choosefolder/ChooseFolderViewModel.kt index 2007ba05c91d8ee70f8fe8e31f0bf3475250a39f..ab9a4b9372ee93612d793bf57fafadab80947433 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/choosefolder/ChooseFolderViewModel.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/choosefolder/ChooseFolderViewModel.kt @@ -20,6 +20,7 @@ class ChooseFolderViewModel(private val folderRepository: FolderRepository) : Vi .flatMapLatest { (account, displayMode) -> folderRepository.getDisplayFoldersFlow(account, displayMode) } + var currentDisplayMode: FolderMode? = null private set @@ -35,4 +36,4 @@ class ChooseFolderViewModel(private val folderRepository: FolderRepository) : Vi } } -private data class DisplayMode(val account: Account, val displayMode: FolderMode) \ No newline at end of file +private data class DisplayMode(val account: Account, val displayMode: FolderMode) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/folders/FoldersViewModel.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/folders/FoldersViewModel.kt index 7a42b42da69d2e1da533d1870ae9bb68d2a03f41..ef66e21cd4a6f78dbd703bd4b26f1cd60c965324 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/folders/FoldersViewModel.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/folders/FoldersViewModel.kt @@ -1,25 +1,24 @@ package com.fsck.k9.ui.folders import androidx.lifecycle.LiveData -import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.ViewModel -import com.fsck.k9.Account -import com.fsck.k9.mailstore.DisplayFolder import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope +import com.fsck.k9.Account +import com.fsck.k9.K9 +import com.fsck.k9.controller.MessageCountsProvider +import com.fsck.k9.mailstore.DisplayFolder import com.fsck.k9.mailstore.FolderRepository +import com.fsck.k9.search.SearchAccount +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.launch import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import com.fsck.k9.search.SearchAccount -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import com.fsck.k9.K9 -import com.fsck.k9.controller.MessageCountsProvider +import kotlinx.coroutines.launch @OptIn(ExperimentalCoroutinesApi::class) class FoldersViewModel( @@ -31,13 +30,20 @@ class FoldersViewModel( private val foldersFlow = inputFlow .flatMapLatest { account -> if (account == null) { - flowOf(emptyList()) + flowOf(0 to emptyList()) } else { folderRepository.getDisplayFoldersFlow(account) + .map { displayFolders -> + account.accountNumber to displayFolders + } } } - .map { displayFolders -> - FolderList(unifiedInbox = createDisplayUnifiedInbox(), folders = displayFolders) + .map { (accountNumber, displayFolders) -> + FolderList( + unifiedInbox = createDisplayUnifiedInbox(), + accountId = accountNumber + 1, + folders = displayFolders + ) } .flowOn(backgroundDispatcher) @@ -61,16 +67,19 @@ class FoldersViewModel( // When switching accounts we want to remove the old list right away, not keep it until the new list // has been loaded. inputFlow.emit(null) + inputFlow.emit(account) } } } + data class FolderList( val unifiedInbox: DisplayUnifiedInbox?, + val accountId: Int, val folders: List ) data class DisplayUnifiedInbox( val unreadMessageCount: Int, val starredMessageCount: Int -) \ No newline at end of file +) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/helper/ContextExtensions.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/helper/ContextExtensions.kt new file mode 100644 index 0000000000000000000000000000000000000000..08b8115fe29c20b29bf0a29948f519b66c1e4dbc --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/helper/ContextExtensions.kt @@ -0,0 +1,11 @@ +@file:JvmName("ContextHelper") +package com.fsck.k9.ui.helper + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper + +// Source: https://stackoverflow.com/a/58249983 +tailrec fun Context.findActivity(): Activity? { + return this as? Activity ?: (this as? ContextWrapper)?.baseContext?.findActivity() +} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/FolderSettingsDataStore.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/FolderSettingsDataStore.kt index d6938b94c80387718e4a62ad76b1c2715bfc96a7..4775b493a9e1733084947a2c1b9893a41c7970dc 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/FolderSettingsDataStore.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/FolderSettingsDataStore.kt @@ -1,19 +1,19 @@ package com.fsck.k9.ui.managefolders import androidx.preference.PreferenceDataStore +import com.fsck.k9.Account import com.fsck.k9.mail.FolderClass import com.fsck.k9.mailstore.FolderDetails import com.fsck.k9.mailstore.FolderRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope -import com.fsck.k9.Account import kotlinx.coroutines.launch class FolderSettingsDataStore( private val folderRepository: FolderRepository, - private var folder: FolderDetails, - private val account: Account + private val account: Account, + private var folder: FolderDetails ) : PreferenceDataStore() { private val saveScope = CoroutineScope(GlobalScope.coroutineContext + Dispatchers.IO) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/AttachmentView.java b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/AttachmentView.java index 8e7934b59a6bd6c345a3da9571860239549df9b0..30e4c686f058665c6e388e7af27815cc00948ab7 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/AttachmentView.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/AttachmentView.java @@ -14,6 +14,7 @@ import com.bumptech.glide.Glide; import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.fsck.k9.K9; import com.fsck.k9.ui.R; +import com.fsck.k9.ui.helper.ContextHelper; import com.fsck.k9.ui.helper.SizeFormatter; import com.fsck.k9.mailstore.AttachmentViewInfo; @@ -122,12 +123,13 @@ public class AttachmentView extends FrameLayout implements OnClickListener { public void refreshThumbnail() { Context context = getContext(); - if (context instanceof Activity && ((Activity) context).isDestroyed()) { + Activity activity = ContextHelper.findActivity(context); + if (activity != null && activity.isDestroyed()) { // Do nothing because Glide would throw an exception return; } preview.setVisibility(View.VISIBLE); - Glide.with(context) + Glide.with(getContext()) .load(attachment.internalUri) .centerCrop() .diskCacheStrategy(DiskCacheStrategy.NONE) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.java b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.java index 7d62b76aaa0a7e47abf84697f9c1d16b9498e8ee..d38cbab8c146cdcb6917ad3c0caebfa9b88e3eca 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.java @@ -385,7 +385,7 @@ public class MessageViewFragment extends Fragment implements ConfirmationDialogF return; } - startRefileActivity(ACTIVITY_CHOOSE_FOLDER_MOVE); + startRefileActivity(FolderOperation.MOVE, ACTIVITY_CHOOSE_FOLDER_MOVE); } @@ -400,7 +400,7 @@ public class MessageViewFragment extends Fragment implements ConfirmationDialogF return; } - startRefileActivity(ACTIVITY_CHOOSE_FOLDER_COPY); + startRefileActivity(FolderOperation.COPY, ACTIVITY_CHOOSE_FOLDER_COPY); } public void onMoveToDrafts() { @@ -421,11 +421,18 @@ public class MessageViewFragment extends Fragment implements ConfirmationDialogF onRefile(mAccount.getSpamFolderId()); } - private void startRefileActivity(int requestCode) { + private void startRefileActivity(FolderOperation operation, int requestCode) { String accountUuid = mAccount.getUuid(); long currentFolderId = mMessageReference.getFolderId(); Long scrollToFolderId = mAccount.getLastSelectedFolderId(); - Intent intent = ChooseFolderActivity.buildLaunchIntent(requireActivity(), accountUuid, currentFolderId, + final ChooseFolderActivity.Action action; + if (operation == FolderOperation.MOVE) { + action = ChooseFolderActivity.Action.MOVE; + } else { + action = ChooseFolderActivity.Action.COPY; + } + + Intent intent = ChooseFolderActivity.buildLaunchIntent(requireActivity(), action, accountUuid, currentFolderId, scrollToFolderId, false, mMessageReference); startActivityForResult(intent, requestCode); @@ -853,4 +860,8 @@ public class MessageViewFragment extends Fragment implements ConfirmationDialogF private AttachmentController getAttachmentController(AttachmentViewInfo attachment) { return new AttachmentController(mController, this, attachment); } + + private enum FolderOperation { + COPY, MOVE + } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/KoinModule.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/KoinModule.kt index 771081230a0592f9175787de5056975b2cb3fe37..c76fd9a8c6927719c457feabd337caf268b78090 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/KoinModule.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/KoinModule.kt @@ -1,11 +1,11 @@ package com.fsck.k9.ui.settings import com.fsck.k9.helper.NamedThreadFactory -import com.fsck.k9.ui.account.AccountsLiveData import com.fsck.k9.ui.settings.account.AccountSettingsDataStoreFactory import com.fsck.k9.ui.settings.account.AccountSettingsViewModel import com.fsck.k9.ui.settings.export.SettingsExportViewModel import com.fsck.k9.ui.settings.general.GeneralSettingsDataStore +import com.fsck.k9.ui.settings.general.GeneralSettingsViewModel import com.fsck.k9.ui.settings.import.AccountActivator import com.fsck.k9.ui.settings.import.SettingsImportResultViewModel import com.fsck.k9.ui.settings.import.SettingsImportViewModel @@ -15,9 +15,9 @@ import org.koin.core.qualifier.named import org.koin.dsl.module val settingsUiModule = module { - factory { AccountsLiveData(get()) } - viewModel { SettingsViewModel(accountManager = get(), accounts = get()) } + viewModel { SettingsViewModel(accountManager = get()) } + viewModel { GeneralSettingsViewModel(logFileWriter = get()) } factory { GeneralSettingsDataStore(jobManager = get(), themeManager = get(), appLanguageManager = get()) } single(named("SaveSettingsExecutorService")) { Executors.newSingleThreadExecutor(NamedThreadFactory("SaveSettings")) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/SettingsViewModel.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/SettingsViewModel.kt index 80d29fb3c002a7cf31f20baf6852450905c5601d..5036cd72a7ff2e84f41c50caa28069d97e7833a8 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/SettingsViewModel.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/SettingsViewModel.kt @@ -1,9 +1,9 @@ package com.fsck.k9.ui.settings import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData import com.fsck.k9.Account import com.fsck.k9.preferences.AccountManager -import com.fsck.k9.ui.account.AccountsLiveData import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -15,8 +15,8 @@ internal class SettingsViewModel( private val accountManager: AccountManager, private val coroutineScope: CoroutineScope = GlobalScope, private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO, - val accounts: AccountsLiveData ) : ViewModel() { + val accounts = accountManager.getAccountsFlow().asLiveData() fun moveAccount(account: Account, newPosition: Int) { coroutineScope.launch(coroutineDispatcher) { diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsFragment.kt index 35363db1406cb9297d6893554d878687eb60e4db..6721ba79acbeb206aee514b6d1f1797b121f254d 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsFragment.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsFragment.kt @@ -409,4 +409,4 @@ class AccountSettingsFragment : PreferenceFragmentCompat(), ConfirmationDialogFr PreferenceFragmentCompat.ARG_PREFERENCE_ROOT to rootKey ) } -} \ No newline at end of file +} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsViewModel.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsViewModel.kt index dc1c2c44b5fa55c946bb69f41c3e8b28e1f7ea60..e35317c60137af889c266cb670101dc45a80dd3b 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsViewModel.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsViewModel.kt @@ -3,35 +3,37 @@ package com.fsck.k9.ui.settings.account import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope import com.fsck.k9.Account -import com.fsck.k9.Preferences import com.fsck.k9.mailstore.FolderRepository import com.fsck.k9.mailstore.FolderType import com.fsck.k9.mailstore.RemoteFolder import com.fsck.k9.mailstore.SpecialFolderSelectionStrategy -import com.fsck.k9.ui.account.AccountsLiveData +import com.fsck.k9.preferences.AccountManager +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class AccountSettingsViewModel( - private val preferences: Preferences, + private val accountManager: AccountManager, private val folderRepository: FolderRepository, - private val specialFolderSelectionStrategy: SpecialFolderSelectionStrategy + private val specialFolderSelectionStrategy: SpecialFolderSelectionStrategy, + private val backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO ) : ViewModel() { - val accounts = AccountsLiveData(preferences) - private val accountLiveData = MutableLiveData() + val accounts = accountManager.getAccountsFlow().asLiveData() + private var accountUuid: String? = null + private val accountLiveData = MutableLiveData() private val foldersLiveData = MutableLiveData() - fun getAccount(accountUuid: String): LiveData { - if (accountLiveData.value == null) { - - GlobalScope.launch(Dispatchers.Main) { - val account = withContext(Dispatchers.IO) { + fun getAccount(accountUuid: String): LiveData { + if (this.accountUuid != accountUuid) { + this.accountUuid = accountUuid + viewModelScope.launch { + val account = withContext(backgroundDispatcher) { loadAccount(accountUuid) } - accountLiveData.value = account } } @@ -43,13 +45,16 @@ class AccountSettingsViewModel( * doesn't support asynchronous preference loading. */ fun getAccountBlocking(accountUuid: String): Account { - return accountLiveData.value ?: loadAccount(accountUuid).also { - accountLiveData.value = it - } + return accountLiveData.value + ?: loadAccount(accountUuid).also { account -> + this.accountUuid = accountUuid + accountLiveData.value = account + } + ?: error("Account $accountUuid not found") } - private fun loadAccount(accountUuid: String): Account { - return preferences.getAccount(accountUuid) ?: error("Account $accountUuid not found") + private fun loadAccount(accountUuid: String): Account? { + return accountManager.getAccount(accountUuid) } fun getFolders(account: Account): LiveData { @@ -61,8 +66,8 @@ class AccountSettingsViewModel( } private fun loadFolders(account: Account) { - GlobalScope.launch(Dispatchers.Main) { - val remoteFolderInfo = withContext(Dispatchers.IO) { + viewModelScope.launch { + val remoteFolderInfo = withContext(backgroundDispatcher) { val folders = folderRepository.getRemoteFolders(account) val automaticSpecialFolders = getAutomaticSpecialFolders(folders) RemoteFolderInfo(folders, automaticSpecialFolders) @@ -70,7 +75,6 @@ class AccountSettingsViewModel( foldersLiveData.value = remoteFolderInfo } } - } private fun getAutomaticSpecialFolders(folders: List): Map { return mapOf( diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsFragment.kt index 7fab73307a71fd35dc2b04d9478638430c54c678..f8d31ccab3d0f6b41c01b2df12c02b297e2f04d6 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsFragment.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsFragment.kt @@ -2,21 +2,49 @@ package com.fsck.k9.ui.settings.general import android.os.Build import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.activity.result.contract.ActivityResultContracts.CreateDocument import androidx.preference.ListPreference import com.fsck.k9.ui.R +import com.fsck.k9.ui.observe import com.fsck.k9.ui.withArguments +import com.google.android.material.snackbar.Snackbar import com.takisoft.preferencex.PreferenceFragmentCompat import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel class GeneralSettingsFragment : PreferenceFragmentCompat() { + private val viewModel: GeneralSettingsViewModel by viewModel() private val dataStore: GeneralSettingsDataStore by inject() + private var rootKey: String? = null + private var currentUiState: GeneralSettingsUiState? = null + private var snackbar: Snackbar? = null + + private val exportLogsResultContract = registerForActivityResult(CreateDocument()) { contentUri -> + if (contentUri != null) { + viewModel.exportLogs(contentUri) + } + } + override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) { preferenceManager.preferenceDataStore = dataStore - + this.rootKey = rootKey + setHasOptionsMenu(true) setPreferencesFromResource(R.xml.general_settings, rootKey) initializeTheme() + + viewModel.uiState.observe(this) { uiState -> + updateUiState(uiState) + } + } + + override fun onDestroyView() { + super.onDestroyView() + dismissSnackbar() } override fun onActivityCreated(savedInstanceState: Bundle?) { @@ -24,6 +52,24 @@ class GeneralSettingsFragment : PreferenceFragmentCompat() { activity?.title = preferenceScreen.title } + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + if (rootKey == PREFERENCE_SCREEN_DEBUGGING) { + inflater.inflate(R.menu.debug_settings_option, menu) + currentUiState?.let { uiState -> + menu.findItem(R.id.exportLogs).isEnabled = uiState.isExportLogsMenuEnabled + } + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == R.id.exportLogs) { + exportLogsResultContract.launch(GeneralSettingsViewModel.DEFAULT_FILENAME) + return true + } + + return super.onOptionsItemSelected(item) + } + private fun initializeTheme() { (findPreference(PREFERENCE_THEME) as? ListPreference)?.apply { if (Build.VERSION.SDK_INT < 28) { @@ -33,8 +79,45 @@ class GeneralSettingsFragment : PreferenceFragmentCompat() { } } + private fun updateUiState(uiState: GeneralSettingsUiState) { + val oldUiState = currentUiState + currentUiState = uiState + + if (oldUiState?.isExportLogsMenuEnabled != uiState.isExportLogsMenuEnabled) { + setExportLogsMenuEnabled() + } + + if (oldUiState?.snackbarState != uiState.snackbarState) { + setSnackbarState(uiState.snackbarState) + } + } + + private fun setExportLogsMenuEnabled() { + requireActivity().invalidateOptionsMenu() + } + + private fun setSnackbarState(snackbarState: SnackbarState) { + when (snackbarState) { + SnackbarState.Hidden -> dismissSnackbar() + SnackbarState.ExportLogSuccess -> showSnackbar(R.string.debug_export_logs_success) + SnackbarState.ExportLogFailure -> showSnackbar(R.string.debug_export_logs_failure) + } + } + + private fun dismissSnackbar() { + snackbar?.dismiss() + snackbar = null + } + + private fun showSnackbar(message: Int) { + Snackbar.make(requireView(), message, Snackbar.LENGTH_INDEFINITE) + .also { snackbar = it } + .show() + } + companion object { private const val PREFERENCE_THEME = "theme" + private const val PREFERENCE_SCREEN_DEBUGGING = "debug_preferences" fun create(rootKey: String? = null) = GeneralSettingsFragment().withArguments(ARG_PREFERENCE_ROOT to rootKey) } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsViewModel.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsViewModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..91407bb039c1d2439ea070861fe2c445a77a7078 --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsViewModel.kt @@ -0,0 +1,91 @@ +package com.fsck.k9.ui.settings.general + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fsck.k9.logging.LogFileWriter +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber + +class GeneralSettingsViewModel(private val logFileWriter: LogFileWriter) : ViewModel() { + private var snackbarJob: Job? = null + private val uiStateFlow = MutableStateFlow(GeneralSettingsUiState.Idle) + val uiState: Flow = uiStateFlow + + fun exportLogs(contentUri: Uri) { + viewModelScope.launch { + setExportingState() + + try { + logFileWriter.writeLogTo(contentUri) + showSnackbar(GeneralSettingsUiState.Success) + } catch (e: Exception) { + Timber.e(e, "Failed to write log to URI: %s", contentUri) + showSnackbar(GeneralSettingsUiState.Failure) + } + } + } + + private fun setExportingState() { + // If an export was triggered before and the success/failure Snackbar is still showing, cancel the coroutine + // that resets the state to Idle after SNACKBAR_DURATION + snackbarJob?.cancel() + snackbarJob = null + + sendUiState(GeneralSettingsUiState.Exporting) + } + + private fun showSnackbar(uiState: GeneralSettingsUiState) { + snackbarJob?.cancel() + snackbarJob = viewModelScope.launch { + sendUiState(uiState) + delay(SNACKBAR_DURATION) + sendUiState(GeneralSettingsUiState.Idle) + snackbarJob = null + } + } + + private fun sendUiState(uiState: GeneralSettingsUiState) { + uiStateFlow.value = uiState + } + + companion object { + const val DEFAULT_FILENAME = "k9mail-logs.txt" + const val SNACKBAR_DURATION = 3000L + } +} + +sealed interface GeneralSettingsUiState { + val isExportLogsMenuEnabled: Boolean + val snackbarState: SnackbarState + + object Idle : GeneralSettingsUiState { + override val isExportLogsMenuEnabled = true + override val snackbarState = SnackbarState.Hidden + } + + object Exporting : GeneralSettingsUiState { + override val isExportLogsMenuEnabled = false + override val snackbarState = SnackbarState.Hidden + } + + object Success : GeneralSettingsUiState { + override val isExportLogsMenuEnabled = true + override val snackbarState = SnackbarState.ExportLogSuccess + } + + object Failure : GeneralSettingsUiState { + override val isExportLogsMenuEnabled = true + override val snackbarState = SnackbarState.ExportLogFailure + } +} + +enum class SnackbarState { + Hidden, + ExportLogSuccess, + ExportLogFailure +} 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 46b0b9633b14e772fe42a04515686ee152376f94..9d494a54fbabbf344999e94808608c90019e21f5 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 @@ -2,28 +2,15 @@ package com.fsck.k9.view; import java.util.Arrays; -import java.util.List; import androidx.annotation.NonNull; import androidx.appcompat.widget.PopupMenu; import androidx.appcompat.widget.PopupMenu.OnMenuItemClickListener; import android.content.Context; -import android.graphics.Typeface; -import android.os.Parcel; -import android.os.Parcelable; - -import androidx.annotation.NonNull; -import androidx.appcompat.widget.PopupMenu; -import androidx.appcompat.widget.PopupMenu.OnMenuItemClickListener; - -import android.text.SpannableString; -import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.text.format.DateUtils; -import android.text.style.StyleSpan; import android.util.AttributeSet; -import android.view.Gravity; import android.view.View; import android.view.View.OnClickListener; import android.view.View.OnLongClickListener; @@ -33,6 +20,9 @@ import android.widget.LinearLayout; import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.appcompat.widget.PopupMenu; +import androidx.appcompat.widget.PopupMenu.OnMenuItemClickListener; import com.fsck.k9.Account; import com.fsck.k9.DI; import com.fsck.k9.FontSizes; @@ -44,9 +34,7 @@ import com.fsck.k9.helper.Contacts; import com.fsck.k9.helper.MessageHelper; import com.fsck.k9.mail.Address; import com.fsck.k9.mail.Flag; -import com.fsck.k9.mail.Header; import com.fsck.k9.mail.Message; -import com.fsck.k9.mail.internet.MimeUtility; import com.fsck.k9.ui.ContactBadge; import com.fsck.k9.ui.R; import com.fsck.k9.ui.messageview.OnCryptoClickListener; @@ -160,7 +148,7 @@ public class MessageHeader extends LinearLayout implements OnClickListener, OnLo } 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); + 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) { @@ -214,7 +202,7 @@ public class MessageHeader extends LinearLayout implements OnClickListener, OnLo } public String createMessageForSubject() { - return mContext.getResources().getString(R.string.copy_subject_to_clipboard); + return mContext.getResources().getString(R.string.copy_subject_to_clipboard); } public String createMessage(int addressesCount) { diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/view/MessageWebView.java b/app/ui/legacy/src/main/java/com/fsck/k9/view/MessageWebView.java index c0069bf373f43676569f54ba09a54095694a8aca..206785237fa09824a23419d4176408d0c56d1bb4 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/view/MessageWebView.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/view/MessageWebView.java @@ -45,7 +45,11 @@ public class MessageWebView extends WebView { * will network images that are already in the WebView cache. * */ - getSettings().setBlockNetworkLoads(shouldBlockNetworkData); + try { + getSettings().setBlockNetworkLoads(shouldBlockNetworkData); + } catch (SecurityException e) { + Timber.e(e, "Failed to unblock network loads. Missing INTERNET permission?"); + } } diff --git a/app/ui/legacy/src/main/res/drawable/btn_check_star.xml b/app/ui/legacy/src/main/res/drawable/btn_check_star.xml index 339bb252837165e0634daf2ded1dc805e41f9175..575615eed5ea729415b463daf3c7c40df61ee5f9 100644 --- a/app/ui/legacy/src/main/res/drawable/btn_check_star.xml +++ b/app/ui/legacy/src/main/res/drawable/btn_check_star.xml @@ -1,5 +1,5 @@ - - + diff --git a/app/ui/legacy/src/main/res/drawable/ic_download.xml b/app/ui/legacy/src/main/res/drawable/ic_download.xml index 2ac322d22a595641c0572eef99f902f27e02ebd2..0964bd62dbdbbc9b97dc3070c88e04e3b5ca1052 100644 --- a/app/ui/legacy/src/main/res/drawable/ic_download.xml +++ b/app/ui/legacy/src/main/res/drawable/ic_download.xml @@ -7,4 +7,4 @@ - \ No newline at end of file + diff --git a/app/ui/legacy/src/main/res/drawable/ic_mark_new.xml b/app/ui/legacy/src/main/res/drawable/ic_mark_new.xml index a543fba47f5a44cc0efd4d0fc5b34a3df609cbdb..de26f783aac2336fe6ea1373161490e29ac770a3 100644 --- a/app/ui/legacy/src/main/res/drawable/ic_mark_new.xml +++ b/app/ui/legacy/src/main/res/drawable/ic_mark_new.xml @@ -7,4 +7,4 @@ - \ No newline at end of file + diff --git a/app/ui/legacy/src/main/res/drawable/ic_refresh_all.xml b/app/ui/legacy/src/main/res/drawable/ic_refresh_all.xml index 16ecaa0e8f1f9b58cafee407c961585e4b8bef6f..f0721d991595c492169dbd00ccce9f656b69320a 100644 --- a/app/ui/legacy/src/main/res/drawable/ic_refresh_all.xml +++ b/app/ui/legacy/src/main/res/drawable/ic_refresh_all.xml @@ -7,4 +7,4 @@ - \ No newline at end of file + diff --git a/app/ui/legacy/src/main/res/layout/account_list.xml b/app/ui/legacy/src/main/res/layout/account_list.xml index 891dfbfd1737000fc60c4ab7c43f5229dc6130d0..b435cb655fcd487089bbf4506c7fc6e9fba51c28 100644 --- a/app/ui/legacy/src/main/res/layout/account_list.xml +++ b/app/ui/legacy/src/main/res/layout/account_list.xml @@ -1,8 +1,10 @@ + android:orientation="vertical" + tools:context="com.fsck.k9.activity.AccountList"> @@ -14,7 +16,8 @@ + android:layout_height="match_parent" + tools:listitem="@layout/accounts_item" /> + android:orientation="vertical" + tools:context="com.fsck.k9.activity.setup.AccountSetupAccountType"> diff --git a/app/ui/legacy/src/main/res/layout/account_setup_basics.xml b/app/ui/legacy/src/main/res/layout/account_setup_basics.xml index fceab80e056f12f3432fe805c83e892b47494848..f0cf0371a7d02eeef95020e337ef1fa14803d490 100644 --- a/app/ui/legacy/src/main/res/layout/account_setup_basics.xml +++ b/app/ui/legacy/src/main/res/layout/account_setup_basics.xml @@ -4,7 +4,8 @@ xmlns:tools="http://schemas.android.com/tools" android:orientation="vertical" android:layout_height="match_parent" - android:layout_width="match_parent"> + android:layout_width="match_parent" + tools:context="com.fsck.k9.activity.setup.AccountSetupBasics"> @@ -95,4 +96,4 @@ - \ No newline at end of file + diff --git a/app/ui/legacy/src/main/res/layout/account_setup_check_settings.xml b/app/ui/legacy/src/main/res/layout/account_setup_check_settings.xml index 48aead19660c0856e2eb6332adbb49a514573097..e2a68d363ad573322054d4e4af4b456c2e2b8f9b 100644 --- a/app/ui/legacy/src/main/res/layout/account_setup_check_settings.xml +++ b/app/ui/legacy/src/main/res/layout/account_setup_check_settings.xml @@ -1,9 +1,11 @@ + android:orientation="vertical" + tools:context="com.fsck.k9.activity.setup.AccountSetupCheckSettings"> diff --git a/app/ui/legacy/src/main/res/layout/account_setup_composition.xml b/app/ui/legacy/src/main/res/layout/account_setup_composition.xml index d04b9f0d2e430713ecf87d683c6139be23e3a8d9..58340ecb1d491cde0c9421e61bb65837edd530a5 100644 --- a/app/ui/legacy/src/main/res/layout/account_setup_composition.xml +++ b/app/ui/legacy/src/main/res/layout/account_setup_composition.xml @@ -1,9 +1,11 @@ + android:orientation="vertical" + tools:context="com.fsck.k9.activity.setup.AccountSetupComposition"> diff --git a/app/ui/legacy/src/main/res/layout/account_setup_incoming.xml b/app/ui/legacy/src/main/res/layout/account_setup_incoming.xml index 5179cdd140c9bc1f54b8a38d78ebf4029ebd8487..c4cfee4fdc8962a2e1a6162115b3a9f720db9f42 100644 --- a/app/ui/legacy/src/main/res/layout/account_setup_incoming.xml +++ b/app/ui/legacy/src/main/res/layout/account_setup_incoming.xml @@ -2,215 +2,216 @@ - + + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:padding="6dip" + android:fadingEdge="none" + android:scrollbarStyle="outsideInset"> + android:layout_width="fill_parent" + android:layout_height="wrap_content" + android:orientation="vertical"> - - + android:layout_marginTop="@dimen/account_setup_margin_between_items_incoming_and_outgoing"> + + + android:text="@string/account_setup_incoming_security_label" + android:layout_height="wrap_content" + android:layout_width="fill_parent" + style="@style/InputLabel" + android:layout_marginTop="6dp"/> + android:id="@+id/account_security_type" + android:layout_height="wrap_content" + android:layout_width="fill_parent" + android:contentDescription="@string/account_setup_incoming_security_label"/> - - + android:layout_marginTop="@dimen/account_setup_margin_between_items_incoming_and_outgoing"> + + - - + android:layout_marginTop="@dimen/account_setup_margin_between_items_incoming_and_outgoing"> + + + android:id="@+id/account_auth_type_label" + android:text="@string/account_setup_incoming_auth_type_label" + android:layout_height="wrap_content" + android:layout_width="match_parent" + android:layout_marginTop="6dp" + style="@style/InputLabel"/> + android:id="@+id/account_auth_type" + android:layout_height="wrap_content" + android:layout_width="match_parent" + android:contentDescription="@string/account_setup_incoming_auth_type_label"/> - - - + android:layout_marginTop="@dimen/account_setup_margin_between_items_incoming_and_outgoing" + app:endIconMode="password_toggle"> - - - + + + + style="@style/InputLabel"/> - - + android:layout_width="match_parent"/> + + android:id="@+id/imap_path_prefix_section" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="6dp" + android:orientation="vertical"> + android:id="@+id/imap_autodetect_namespace" + android:layout_height="wrap_content" + android:layout_width="wrap_content" + android:text="@string/account_setup_incoming_autodetect_namespace_label"/> + android:layout_width="match_parent" + android:layout_height="wrap_content"> + android:id="@+id/imap_path_prefix" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:hint="@string/account_setup_incoming_imap_path_prefix_label" + android:singleLine="true" /> + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:id="@+id/imap_folder_setup_section" + android:orientation="vertical"> + android:id="@+id/subscribed_folders_only" + android:layout_height="wrap_content" + android:layout_width="wrap_content" + android:text="@string/account_setup_incoming_subscribed_folders_only_label" + /> - - + android:orientation="vertical"> + + + android:id="@+id/webdav_mailbox_alias_section" + android:layout_width="fill_parent" + android:layout_height="wrap_content" + android:orientation="vertical"> + android:layout_width="match_parent" + android:layout_height="wrap_content"> + android:id="@+id/webdav_mailbox_path" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:hint="@string/account_setup_incoming_webdav_mailbox_path_label" + android:singleLine="true" /> + android:id="@+id/webdav_owa_path_section" + android:layout_width="fill_parent" + android:layout_height="wrap_content" + android:orientation="vertical"> + android:id="@+id/webdav_auth_path_section" + android:layout_width="fill_parent" + android:layout_height="wrap_content" + android:orientation="vertical"> + android:id="@+id/compression_label" + android:text="@string/account_setup_incoming_compression_label" + android:layout_height="wrap_content" + android:layout_width="match_parent" + android:layout_marginTop="6dp" + style="@style/InputLabel"/> + android:id="@+id/compression_section" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal"> + android:id="@+id/compression_mobile" + android:layout_height="wrap_content" + android:layout_width="wrap_content" + android:text="@string/account_setup_incoming_mobile_label" + /> + android:id="@+id/compression_wifi" + android:layout_height="wrap_content" + android:layout_width="wrap_content" + android:text="@string/account_setup_incoming_wifi_label" + android:contentDescription="@string/account_setup_incoming_compression_label" + /> + android:id="@+id/compression_other" + android:layout_height="wrap_content" + android:layout_width="wrap_content" + android:text="@string/account_setup_incoming_other_label" + android:contentDescription="@string/account_setup_incoming_compression_label" + /> + android:layout_width="match_parent" + android:layout_height="0dip" + android:layout_weight="1"/> - + diff --git a/app/ui/legacy/src/main/res/layout/account_setup_names.xml b/app/ui/legacy/src/main/res/layout/account_setup_names.xml index 4b9e5d662b780843a5402cd3507297bea8907958..2a9f68c12b26aa2d9f4e0e853f50aa8d07762966 100644 --- a/app/ui/legacy/src/main/res/layout/account_setup_names.xml +++ b/app/ui/legacy/src/main/res/layout/account_setup_names.xml @@ -1,9 +1,11 @@ + android:orientation="vertical" + tools:context="com.fsck.k9.activity.setup.AccountSetupNames"> diff --git a/app/ui/legacy/src/main/res/layout/account_setup_options.xml b/app/ui/legacy/src/main/res/layout/account_setup_options.xml index 3e51349b7b18e0fe446eba9c89b5d943b873b960..d15bc14c906db7d72b20f260535f1a3d2181eb67 100644 --- a/app/ui/legacy/src/main/res/layout/account_setup_options.xml +++ b/app/ui/legacy/src/main/res/layout/account_setup_options.xml @@ -1,9 +1,11 @@ + android:orientation="vertical" + tools:context="com.fsck.k9.activity.setup.AccountSetupOptions"> diff --git a/app/ui/legacy/src/main/res/layout/account_setup_outgoing.xml b/app/ui/legacy/src/main/res/layout/account_setup_outgoing.xml index 16b3a6ed492953f43e7e0d38d1355f8b20011140..bdf554681ab152fbc0fdea51dbd5e070033b48cc 100644 --- a/app/ui/legacy/src/main/res/layout/account_setup_outgoing.xml +++ b/app/ui/legacy/src/main/res/layout/account_setup_outgoing.xml @@ -5,7 +5,8 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:orientation="vertical" android:layout_height="match_parent" - android:layout_width="match_parent"> + android:layout_width="match_parent" + tools:context="com.fsck.k9.activity.setup.AccountSetupOutgoing"> diff --git a/app/ui/legacy/src/main/res/layout/accounts_item.xml b/app/ui/legacy/src/main/res/layout/accounts_item.xml index f40046c000eaecce4b57fc5b46f6b032f58fe341..df4b6b4e163a484e070ab1412b055eedd4a08a4f 100644 --- a/app/ui/legacy/src/main/res/layout/accounts_item.xml +++ b/app/ui/legacy/src/main/res/layout/accounts_item.xml @@ -1,19 +1,22 @@ - + + android:id="@+id/chip" + android:layout_height="match_parent" + android:layout_width="8dip" + android:layout_marginRight="8dip" + tools:background="@sample/accounts.json/data/color" + /> + android:id="@+id/description" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:singleLine="true" + android:ellipsize="end" + android:textColor="?android:attr/textColorPrimary" + android:textAppearance="@style/TextAppearance.K9.Medium" + tools:text="@sample/accounts.json/data/name"/> + android:id="@+id/email" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:singleLine="true" + android:ellipsize="end" + android:textColor="?android:attr/textColorSecondary" + android:textAppearance="@style/TextAppearance.K9.Small" + tools:text="@sample/accounts.json/data/email"/> diff --git a/app/ui/legacy/src/main/res/layout/activity_account_settings.xml b/app/ui/legacy/src/main/res/layout/activity_account_settings.xml index 3883c497497700bc9c6cd2775d0bd0b72ebf68c8..50494fbf33129b4f1904f9111fc9c55ce65b605d 100644 --- a/app/ui/legacy/src/main/res/layout/activity_account_settings.xml +++ b/app/ui/legacy/src/main/res/layout/activity_account_settings.xml @@ -1,20 +1,24 @@ + android:orientation="vertical" + tools:context=".settings.account.AccountSettingsActivity"> + android:elevation="4dp" + tools:navigationIcon="@drawable/ic_arrow_back"> + android:layout_height="wrap_content" + tools:listitem="@layout/account_spinner_item"/> diff --git a/app/ui/legacy/src/main/res/layout/activity_manage_folders.xml b/app/ui/legacy/src/main/res/layout/activity_manage_folders.xml index 383163e67f02325e74f942d887a6cb0c4f929851..4264fe093a4ceaa5f8370162b59ccf27f10e650c 100644 --- a/app/ui/legacy/src/main/res/layout/activity_manage_folders.xml +++ b/app/ui/legacy/src/main/res/layout/activity_manage_folders.xml @@ -2,9 +2,11 @@ + android:orientation="vertical" + tools:context=".managefolders.ManageFoldersActivity"> @@ -14,6 +16,7 @@ android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" - app:defaultNavHost="true" /> + app:defaultNavHost="true" + tools:layout="@layout/fragment_manage_folders"/> diff --git a/app/ui/legacy/src/main/res/layout/activity_push_info.xml b/app/ui/legacy/src/main/res/layout/activity_push_info.xml index ae38f5ac311c19007e6e2deb6461dbb269dad722..93ed22be13ad2f387614563eedf2bae7e703626e 100644 --- a/app/ui/legacy/src/main/res/layout/activity_push_info.xml +++ b/app/ui/legacy/src/main/res/layout/activity_push_info.xml @@ -1,9 +1,11 @@ + android:orientation="vertical" + tools:context=".push.PushInfoActivity"> @@ -11,6 +13,7 @@ android:id="@+id/fragment_container" android:layout_width="match_parent" android:layout_height="0dp" - android:layout_weight="1" /> + android:layout_weight="1" + tools:layout="@layout/fragment_push_info" /> diff --git a/app/ui/legacy/src/main/res/layout/activity_recent_changes.xml b/app/ui/legacy/src/main/res/layout/activity_recent_changes.xml index ae38f5ac311c19007e6e2deb6461dbb269dad722..62ea850fdb1f0f7cd1d696e391181468d42777dd 100644 --- a/app/ui/legacy/src/main/res/layout/activity_recent_changes.xml +++ b/app/ui/legacy/src/main/res/layout/activity_recent_changes.xml @@ -1,9 +1,11 @@ + android:orientation="vertical" + tools:context=".changelog.RecentChangesActivity"> @@ -11,6 +13,7 @@ android:id="@+id/fragment_container" android:layout_width="match_parent" android:layout_height="0dp" - android:layout_weight="1" /> + android:layout_weight="1" + tools:layout="@layout/fragment_changelog" /> diff --git a/app/ui/legacy/src/main/res/layout/activity_settings.xml b/app/ui/legacy/src/main/res/layout/activity_settings.xml index 491048ad3d46ae9275d8de55c08790085efcca51..ecfaf19a42acb8477f21778bf9066c0d25ded7f7 100644 --- a/app/ui/legacy/src/main/res/layout/activity_settings.xml +++ b/app/ui/legacy/src/main/res/layout/activity_settings.xml @@ -1,10 +1,11 @@ - + android:orientation="vertical" + tools:context=".settings.SettingsActivity"> diff --git a/app/ui/legacy/src/main/res/layout/choose_account_item.xml b/app/ui/legacy/src/main/res/layout/choose_account_item.xml index 5bcf7a58d91fa25351890ad8932bee0353b579a3..64136c807151474d98393706c3a9d001d237e096 100644 --- a/app/ui/legacy/src/main/res/layout/choose_account_item.xml +++ b/app/ui/legacy/src/main/res/layout/choose_account_item.xml @@ -1,6 +1,7 @@ + android:layout_width="6dp" + tools:background="@sample/accounts.json/data/color"/> + android:textAppearance="?android:attr/textAppearanceMedium" + tools:text="@sample/accounts.json/data/name"/> diff --git a/app/ui/legacy/src/main/res/layout/choose_identity_item.xml b/app/ui/legacy/src/main/res/layout/choose_identity_item.xml index e641a4025ef4efee31496a4c90995cd7177608d5..fd420f09c72917e7e4b0e1dfbb3d0fce8409b215 100644 --- a/app/ui/legacy/src/main/res/layout/choose_identity_item.xml +++ b/app/ui/legacy/src/main/res/layout/choose_identity_item.xml @@ -1,6 +1,7 @@ + android:textAppearance="?android:attr/textAppearanceSmall" + tools:text="Initial identity" /> + android:textAppearance="?android:attr/textAppearanceSmall" + tools:text="Firstname Lastname <user@domain.example>" /> diff --git a/app/ui/legacy/src/main/res/layout/client_certificate_spinner.xml b/app/ui/legacy/src/main/res/layout/client_certificate_spinner.xml index fdfda026b056f25f39509f91a60bcdabae527ca0..063942285f6f1e882341458c994dcab85ad96647 100644 --- a/app/ui/legacy/src/main/res/layout/client_certificate_spinner.xml +++ b/app/ui/legacy/src/main/res/layout/client_certificate_spinner.xml @@ -1,7 +1,12 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="wrap_content" + tools:parentTag="LinearLayout" + tools:orientation="horizontal">