diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 4cb27b71aaeb5729d4ce357698e3936979bfbee6..0000000000000000000000000000000000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -name: Bug report -about: For reproducible bugs -title: '' -labels: bug -assignees: '' - ---- - -Please search to check for an existing issue (including closed issues, for which the fix may not have yet been released) before opening a new issue: https://github.com/k9mail/k-9/issues?q=is%3Aissue - -If you are unsure whether or not you have found a bug please post to the support forum instead: https://forum.k9mail.app - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '...' -3. Scroll down to '...' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Environment (please complete the following information):** - - K-9 Mail version: [e.g. 5.718] - - Android version: [e.g. 10] - - Device: [e.g. Google Pixel 4] - - Account type: [e.g. IMAP, POP3, WebDAV/Exchange] - -**Additional context** -Add any other context about the problem here. - -**Logs** -Please take some time to [retrieve logs](https://github.com/k9mail/k-9/wiki/LoggingErrors) and attach them here: - diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000000000000000000000000000000000..a746ed32f7cd59898e0963aec13b70253747dbc0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,77 @@ +name: Bug report +description: Let us know about crashes or existing functionality not working like it should. +labels: [ "bug" ] +body: + - type: markdown + attributes: + value: Thanks for taking the time to fill out this bug report! + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: I have used the search function to see if someone else has already submitted the same bug report. + required: true + - label: I will describe the problem with as much detail as possible. + required: true + - type: input + id: version + attributes: + label: App version + description: What's the "latest version" changes all the time, so we need the exact version number. You can find it inside the app under *Settings → About*. + placeholder: x.yyy + validations: + required: true + - type: dropdown + id: source + attributes: + label: Where did you get the app from? + multiple: false + options: + - Google Play + - F-Droid + - Other + - type: input + id: android_version + attributes: + label: Android version + description: Please mention if you are using a custom ROM! + validations: + required: true + - type: input + id: device + attributes: + label: Device model + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. Go to '…' + 2. Click on '…' + 3. Scroll down to '…' + 4. etc. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: After following the steps, what did you think K-9 Mail would do? + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: What did K-9 Mail do instead? Screenshots might help. Usually, you can take a screenshot by pressing *Power* + *Volume down* for a few seconds. + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs + description: | + Please take some time to [retrieve logs](https://github.com/k9mail/k-9/wiki/LoggingErrors) and attach them here. + + Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 1ce9915ba5b11232604d9f8bfebe49c62dc06fce..1b65bc7bd8b013f1431e844dbb51f6069af1a61f 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,9 +1,5 @@ blank_issues_enabled: false contact_links: - - name: Support request - url: https://forum.k9mail.app/c/support - about: Ask the community for help - - name: Question? - url: https://forum.k9mail.app/c/general - about: For general questions about the project - + - name: K-9 Mail Forum + url: https://forum.k9mail.app/ + about: Most issues are not bugs. Ask the community for help. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index b616b2eba39f0f8058b0a5ba0eaa34b9bd4516f6..0000000000000000000000000000000000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for K-9 Mail -title: '' -labels: enhancement -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. - diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000000000000000000000000000000000000..491635265f9fdfac6b036d415b2272a1fecf6cab --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,45 @@ +name: Feature request +description: Suggest an idea for K-9 Mail +labels: [ "enhancement" ] +body: + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: I have used the search function to see if someone else has already submitted the same feature request. + required: true + - label: I will describe the problem with as much detail as possible. + required: true + - label: This issue only contains a request for one single feature, **not** multiple (related) features. + required: true + - type: input + id: version + attributes: + label: App version + description: The app version you are currently using. You can find it inside the app under *Settings → About*. + placeholder: x.yyy + validations: + required: true + - type: textarea + id: problem + attributes: + label: Problem you are trying to solve + description: Give a brief explanation of the problem you are trying to solve. + validations: + required: true + - type: textarea + id: solution + attributes: + label: Suggested solution + description: Describe how you would like the app to help you solve that problem. Try to be as specific as possible. + validations: + required: true + - type: textarea + id: screenshots + attributes: + label: Screenshots / Drawings / Technical details + description: | + If your request is about (or includes) changing or extending the user interface (UI), describe what the UI would look like and how the user would interact with it. + + Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 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/build.gradle b/app/autodiscovery/providersxml/build.gradle index f427e7836739b96790f1fc5dca6da358fbd4e6df..18cc6a8682470f1ad52cf33ace9487dc07c7220c 100644 --- a/app/autodiscovery/providersxml/build.gradle +++ b/app/autodiscovery/providersxml/build.gradle @@ -16,7 +16,7 @@ dependencies { testImplementation "com.google.truth:truth:${versions.truth}" testImplementation "org.mockito:mockito-core:${versions.mockito}" testImplementation "org.mockito.kotlin:mockito-kotlin:${versions.mockitoKotlin}" - testImplementation "org.koin:koin-test:${versions.koin}" + testImplementation "io.insert-koin:koin-test-junit4:${versions.koin}" } android { diff --git a/app/autodiscovery/providersxml/src/main/res/xml/providers.xml b/app/autodiscovery/providersxml/src/main/res/xml/providers.xml index fa728c1758da096d2908bb81b643186c77a596d6..c7bee9b7510140b226bb65e3f029c1f7ac642513 100644 --- a/app/autodiscovery/providersxml/src/main/res/xml/providers.xml +++ b/app/autodiscovery/providersxml/src/main/res/xml/providers.xml @@ -284,6 +284,16 @@ + + + + + + + + + + diff --git a/app/autodiscovery/srvrecords/build.gradle b/app/autodiscovery/srvrecords/build.gradle index 565bf3659df6394fdfbefabfaf86c48d21b78d42..2ebb818f17020b4170cc45deb8b2fd62b157fa3f 100644 --- a/app/autodiscovery/srvrecords/build.gradle +++ b/app/autodiscovery/srvrecords/build.gradle @@ -17,7 +17,7 @@ dependencies { testImplementation "com.google.truth:truth:${versions.truth}" testImplementation "org.mockito:mockito-core:${versions.mockito}" testImplementation "org.mockito.kotlin:mockito-kotlin:${versions.mockitoKotlin}" - testImplementation "org.koin:koin-test:${versions.koin}" + testImplementation "io.insert-koin:koin-test-junit4:${versions.koin}" } android { diff --git a/app/autodiscovery/thunderbird/build.gradle b/app/autodiscovery/thunderbird/build.gradle index afad77f1a66f62f78e0b8344e0b8ebd59e30f99c..ed21e968f289f614f7682c7db5425e7b64c47a0e 100644 --- a/app/autodiscovery/thunderbird/build.gradle +++ b/app/autodiscovery/thunderbird/build.gradle @@ -17,7 +17,7 @@ dependencies { testImplementation "com.google.truth:truth:${versions.truth}" testImplementation "org.mockito:mockito-core:${versions.mockito}" testImplementation "org.mockito.kotlin:mockito-kotlin:${versions.mockitoKotlin}" - testImplementation "org.koin:koin-test:${versions.koin}" + testImplementation "io.insert-koin:koin-test-junit4:${versions.koin}" } android { diff --git a/app/core/build.gradle b/app/core/build.gradle index 50526b3e1578357e72697b4573cd7d550f41aa57..8bb6de20722cd8dfd153ac6b4decdf149e85effd 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -8,7 +8,7 @@ dependencies { implementation project(':plugins:openpgp-api-lib:openpgp-api') - api "org.koin:koin-androidx-viewmodel:${versions.koin}" + api "io.insert-koin:koin-android:${versions.koin}" api "androidx.annotation:annotation:${versions.androidxAnnotation}" @@ -35,7 +35,7 @@ dependencies { testImplementation "org.mockito:mockito-core:${versions.mockito}" testImplementation "org.mockito.kotlin:mockito-kotlin:${versions.mockitoKotlin}" testImplementation "org.jdom:jdom2:2.0.6" - testImplementation "org.koin:koin-test:${versions.koin}" + testImplementation "io.insert-koin:koin-test:${versions.koin}" } android { 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 4e0a35ebf86d1ce924478185497d964d714b7fd9..5f1fdcdad68fa98a300e19fcdaa8df75e6643352 100644 --- a/app/core/src/main/java/com/fsck/k9/Account.java +++ b/app/core/src/main/java/com/fsck/k9/Account.java @@ -11,15 +11,12 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import android.content.Context; import android.text.TextUtils; import com.fsck.k9.backend.api.SyncConfig.ExpungePolicy; import com.fsck.k9.mail.Address; import com.fsck.k9.mail.NetworkType; import com.fsck.k9.mail.ServerSettings; -import com.fsck.k9.mailstore.StorageManager; -import com.fsck.k9.mailstore.StorageManager.StorageProvider; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -119,6 +116,7 @@ public class Account implements BaseAccount { private FolderMode folderNotifyNewMailMode; private boolean notifySelfNewMail; private boolean notifyContactsMailOnly; + private boolean ignoreChatMessages; private String legacyInboxFolder; private String importedDraftsFolder; private String importedSentFolder; @@ -157,9 +155,6 @@ public class Account implements BaseAccount { private boolean subscribedFoldersOnly; private int maximumPolledMessageAge; private int maximumAutoDownloadMessageSize; - // Tracks if we have sent a notification for this account for - // current set of fetched messages - private boolean ringNotified; private MessageFormat messageFormat; private boolean messageFormatAuto; private boolean messageReadReceipt; @@ -185,6 +180,7 @@ public class Account implements BaseAccount { private long lastSyncTime; private long lastFolderListRefreshTime; private boolean isFinishedSetup = false; + private int messagesNotificationChannelVersion; private boolean changedVisibleLimits = false; @@ -316,15 +312,6 @@ public class Account implements BaseAccount { this.alwaysBcc = alwaysBcc; } - /* Have we sent a new mail notification on this account */ - public boolean isRingNotified() { - return ringNotified; - } - - public void setRingNotified(boolean ringNotified) { - this.ringNotified = ringNotified; - } - public String getLocalStorageProviderId() { return localStorageProviderId; } @@ -621,6 +608,18 @@ public class Account implements BaseAccount { this.notifySync = notifySync; } + public synchronized int getMessagesNotificationChannelVersion() { + return messagesNotificationChannelVersion; + } + + public synchronized void setMessagesNotificationChannelVersion(int notificationChannelVersion) { + messagesNotificationChannelVersion = notificationChannelVersion; + } + + public synchronized void incrementMessagesNotificationChannelVersion() { + messagesNotificationChannelVersion++; + } + public synchronized SortType getSortType() { return sortType; } @@ -680,6 +679,14 @@ public class Account implements BaseAccount { this.notifyContactsMailOnly = notifyContactsMailOnly; } + public synchronized boolean isIgnoreChatMessages() { + return ignoreChatMessages; + } + + public synchronized void setIgnoreChatMessages(boolean ignoreChatMessages) { + this.ignoreChatMessages = ignoreChatMessages; + } + public synchronized Expunge getExpungePolicy() { return expungePolicy; } @@ -1034,16 +1041,6 @@ public class Account implements BaseAccount { return notificationSetting; } - /** - * @return true if our {@link StorageProvider} is ready. (e.g. - * card inserted) - */ - public boolean isAvailable(Context context) { - String localStorageProviderId = getLocalStorageProviderId(); - boolean storageProviderIsInternalMemory = localStorageProviderId == null; - return storageProviderIsInternalMemory || StorageManager.getInstance(context).isReady(localStorageProviderId); - } - public synchronized boolean isMarkMessageAsReadOnView() { return markMessageAsReadOnView; } 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 c84c699fa14de1f91853fc021eea799ec7105e58..bcee0a8eb90cc9521bcf24e7527429982d06cb25 100644 --- a/app/core/src/main/java/com/fsck/k9/AccountPreferenceSerializer.kt +++ b/app/core/src/main/java/com/fsck/k9/AccountPreferenceSerializer.kt @@ -51,7 +51,9 @@ 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) + messagesNotificationChannelVersion = storage.getInt("$accountUuid.messagesNotificationChannelVersion", 0) deletePolicy = DeletePolicy.fromInt(storage.getInt("$accountUuid.deletePolicy", DeletePolicy.NEVER.setting)) legacyInboxFolder = storage.getString("$accountUuid.inboxFolderName", null) importedDraftsFolder = storage.getString("$accountUuid.draftsFolderName", null) @@ -256,7 +258,9 @@ class AccountPreferenceSerializer( editor.putString("$accountUuid.folderNotifyNewMailMode", folderNotifyNewMailMode.name) editor.putBoolean("$accountUuid.notifySelfNewMail", isNotifySelfNewMail) editor.putBoolean("$accountUuid.notifyContactsMailOnly", isNotifyContactsMailOnly) + editor.putBoolean("$accountUuid.ignoreChatMessages", isIgnoreChatMessages) editor.putBoolean("$accountUuid.notifyMailCheck", isNotifySync) + editor.putInt("$accountUuid.messagesNotificationChannelVersion", messagesNotificationChannelVersion) editor.putInt("$accountUuid.deletePolicy", deletePolicy.setting) editor.putString("$accountUuid.inboxFolderName", legacyInboxFolder) editor.putString("$accountUuid.draftsFolderName", importedDraftsFolder) @@ -379,6 +383,8 @@ class AccountPreferenceSerializer( editor.remove("$accountUuid.lastAutomaticCheckTime") editor.remove("$accountUuid.notifyNewMail") editor.remove("$accountUuid.notifySelfNewMail") + editor.remove("$accountUuid.ignoreChatMessages") + editor.remove("$accountUuid.messagesNotificationChannelVersion") editor.remove("$accountUuid.deletePolicy") editor.remove("$accountUuid.draftsFolderName") editor.remove("$accountUuid.sentFolderName") @@ -551,6 +557,8 @@ class AccountPreferenceSerializer( isNotifySync = false isNotifySelfNewMail = true isNotifyContactsMailOnly = false + isIgnoreChatMessages = false + messagesNotificationChannelVersion = 0 folderDisplayMode = FolderMode.NOT_SECOND_CLASS folderSyncMode = FolderMode.FIRST_CLASS folderPushMode = FolderMode.NONE 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/Core.kt b/app/core/src/main/java/com/fsck/k9/Core.kt index 003608fe8acc20c787217415087619e62fe561f1..7282a86526de2dc9da6e27d4d8ddc2e16e66b0e5 100644 --- a/app/core/src/main/java/com/fsck/k9/Core.kt +++ b/app/core/src/main/java/com/fsck/k9/Core.kt @@ -2,40 +2,39 @@ package com.fsck.k9 import android.content.ComponentName import android.content.Context -import android.content.Intent -import android.content.IntentFilter import android.content.pm.PackageManager -import android.os.Handler -import android.os.Looper import com.fsck.k9.job.K9JobManager import com.fsck.k9.mail.internet.BinaryTempFileBody -import com.fsck.k9.service.StorageGoneReceiver -import java.util.concurrent.SynchronousQueue -import timber.log.Timber +import com.fsck.k9.notification.NotificationController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.koin.core.qualifier.named object Core : EarlyInit { private val context: Context by inject() private val appConfig: AppConfig by inject() private val jobManager: K9JobManager by inject() + private val appCoroutineScope: CoroutineScope by inject(named("AppCoroutineScope")) + private val preferences: Preferences by inject() + private val notificationController: NotificationController by inject() /** * This needs to be called from [Application#onCreate][android.app.Application#onCreate] before calling through * to the super class's `onCreate` implementation and before initializing the dependency injection library. */ - fun earlyInit(context: Context) { + fun earlyInit() { if (K9.DEVELOPER_MODE) { enableStrictMode() } - - val packageName = context.packageName - K9.Intents.init(packageName) } fun init(context: Context) { BinaryTempFileBody.setTempDirectory(context.cacheDir) setServicesEnabled(context) - registerReceivers(context) + + restoreNotifications() } /** @@ -80,41 +79,10 @@ object Core : EarlyInit { } } - /** - * Register BroadcastReceivers programmatically because doing it from manifest - * would make K-9 auto-start. We don't want auto-start because the initialization - * sequence isn't safe while some events occur (SD card unmount). - */ - private fun registerReceivers(context: Context) { - val receiver = StorageGoneReceiver() - val filter = IntentFilter() - filter.addAction(Intent.ACTION_MEDIA_EJECT) - filter.addAction(Intent.ACTION_MEDIA_UNMOUNTED) - filter.addDataScheme("file") - - val queue = SynchronousQueue() - - // starting a new thread to handle unmount events - Thread( - Runnable { - Looper.prepare() - try { - queue.put(Handler()) - } catch (e: InterruptedException) { - Timber.e(e) - } - - Looper.loop() - }, - "Unmount-thread" - ).start() - - try { - val storageGoneHandler = queue.take() - context.registerReceiver(receiver, filter, null, storageGoneHandler) - Timber.i("Registered: unmount receiver") - } catch (e: InterruptedException) { - Timber.e(e, "Unable to register unmount receiver") + private fun restoreNotifications() { + appCoroutineScope.launch(Dispatchers.IO) { + val accounts = preferences.accounts + notificationController.restoreNewMailNotifications(accounts) } } } 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..1c8a9b13e6732124f1083f149879b2df2e09a547 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 @@ -27,8 +24,6 @@ interface CoreResourceProvider { fun replyHeader(sender: String): String fun replyHeader(sender: String, sentDate: String): String - fun searchAllMessagesTitle(): String - fun searchAllMessagesDetail(): String fun searchUnifiedInboxTitle(): String fun searchUnifiedInboxDetail(): String diff --git a/app/core/src/main/java/com/fsck/k9/DI.kt b/app/core/src/main/java/com/fsck/k9/DI.kt index 8191e8c71dcd7ebffaf192fcaf3ef6e077073740..00adb26ba187f060f8e1c4026788fd0be8331e61 100644 --- a/app/core/src/main/java/com/fsck/k9/DI.kt +++ b/app/core/src/main/java/com/fsck/k9/DI.kt @@ -29,6 +29,10 @@ object DI { fun get(clazz: Class): T { return koinGet(clazz) } + + inline fun get(): T { + return koinGet(T::class.java) + } } interface EarlyInit 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 98a96dc7cc8bf5d77b958fc8a5433249f08d58d8..1fe5fafaa4132876a6166163dd41e3f7cd8632dd 100644 --- a/app/core/src/main/java/com/fsck/k9/K9.kt +++ b/app/core/src/main/java/com/fsck/k9/K9.kt @@ -119,15 +119,6 @@ object K9 : EarlyInit { @JvmStatic var k9Language = "" - @JvmStatic - var appTheme = AppTheme.FOLLOW_SYSTEM - - var messageViewTheme = SubTheme.USE_GLOBAL - var messageComposeTheme = SubTheme.USE_GLOBAL - - @JvmStatic - var isFixedMessageViewTheme = true - @JvmStatic val fontSizes = FontSizes() @@ -149,6 +140,16 @@ object K9 : EarlyInit { @JvmStatic var isConfirmSpam = false + @JvmStatic + var appTheme = AppTheme.FOLLOW_SYSTEM + + var messageViewTheme = SubTheme.USE_GLOBAL + var messageComposeTheme = SubTheme.USE_GLOBAL + + @JvmStatic + var isFixedMessageViewTheme = true + + @JvmStatic var isConfirmDeleteFromNotification = true @@ -206,6 +207,9 @@ object K9 : EarlyInit { @JvmStatic var isShowUnifiedInbox = true + @JvmStatic + var isShowStarredCount = false + @JvmStatic var isAutoFitWidth: Boolean = false @@ -271,7 +275,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 } @@ -310,6 +315,7 @@ object K9 : EarlyInit { isUseVolumeKeysForNavigation = storage.getBoolean("useVolumeKeysForNavigation", false) isUseVolumeKeysForListNavigation = storage.getBoolean("useVolumeKeysForListNavigation", false) isShowUnifiedInbox = storage.getBoolean("showUnifiedInbox", true) + isShowStarredCount = storage.getBoolean("showStarredCount", false) isMessageListSenderAboveSubject = storage.getBoolean("messageListSenderAboveSubject", false) isShowMessageListStars = storage.getBoolean("messageListStars", true) messageListPreviewLines = storage.getInt("messageListPreviewLines", 2) @@ -346,7 +352,6 @@ object K9 : EarlyInit { val sortAscendingSetting = storage.getBoolean("sortAscending", Account.DEFAULT_SORT_ASCENDING) sortAscending[sortType] = sortAscendingSetting - notificationHideSubject = storage.getEnum("notificationHideSubject", NotificationHideSubject.NEVER) notificationQuickDeleteBehaviour = storage.getEnum("notificationQuickDelete", NotificationQuickDelete.ALWAYS) lockScreenNotificationVisibility = storage.getEnum( @@ -375,7 +380,7 @@ object K9 : EarlyInit { k9Language = storage.getString("language", "") - appTheme = AppTheme.FOLLOW_SYSTEM //storage.getEnum("theme", AppTheme.FOLLOW_SYSTEM) + appTheme = AppTheme.FOLLOW_SYSTEM // storage.getEnum("theme", AppTheme.FOLLOW_SYSTEM) messageViewTheme = storage.getEnum("messageViewTheme", SubTheme.USE_GLOBAL) messageComposeTheme = storage.getEnum("messageComposeTheme", SubTheme.USE_GLOBAL) @@ -394,10 +399,10 @@ object K9 : EarlyInit { editor.putBoolean("notificationDuringQuietTimeEnabled", isNotificationDuringQuietTimeEnabled) editor.putString("quietTimeStarts", quietTimeStarts) editor.putString("quietTimeEnds", quietTimeEnds) - editor.putBoolean("isSentSoundEnabled", isSentSoundEnabled) editor.putBoolean("messageListSenderAboveSubject", isMessageListSenderAboveSubject) editor.putBoolean("showUnifiedInbox", isShowUnifiedInbox) + editor.putBoolean("showStarredCount", isShowStarredCount) editor.putBoolean("messageListStars", isShowMessageListStars) editor.putInt("messageListPreviewLines", messageListPreviewLines) editor.putBoolean("showCorrespondentNames", isShowCorrespondentNames) 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 de26547cc9c74d5b310525f60399c921a30a45b4..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 @@ -19,7 +17,6 @@ val mainModule = module { single(named("AppCoroutineScope")) { GlobalScope } single { Preferences( - context = get(), storagePersister = get(), localStoreProvider = get(), accountPreferenceSerializer = get() @@ -28,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 0a71b46cfb9b9c0af651ea47692469975b4d682c..18441845ba19456be441f16435ff1a5db98eb7bb 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 @@ -18,7 +19,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.channels.sendBlocking import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.callbackFlow @@ -26,7 +26,6 @@ import kotlinx.coroutines.flow.flowOn import timber.log.Timber class Preferences internal constructor( - private val context: Context, private val storagePersister: StoragePersister, private val localStoreProvider: LocalStoreProvider, private val accountPreferenceSerializer: AccountPreferenceSerializer, @@ -104,9 +103,6 @@ class Preferences internal constructor( } } - val availableAccounts: Collection - get() = accounts.filter { it.isAvailable(context) } - override fun getAccount(accountUuid: String): Account? { synchronized(accountLock) { if (accountsMap == null) { @@ -119,20 +115,21 @@ 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 { val account = getAccount(accountUuid) if (account != null) { - try { - sendBlocking(account) - } catch (e: Exception) { - Timber.w(e, "Error while trying to send to channel") - } + sendBlockingSilently(account) } else { - channel.close() + close() } } addOnAccountsChangeListener(listener) @@ -144,6 +141,23 @@ class Preferences internal constructor( .flowOn(backgroundDispatcher) } + @OptIn(ExperimentalCoroutinesApi::class) + override fun getAccountsFlow(): Flow> { + return callbackFlow { + send(accounts) + + val listener = AccountsChangeListener { + sendBlockingSilently(accounts) + } + addOnAccountsChangeListener(listener) + + awaitClose { + removeOnAccountsChangeListener(listener) + } + }.buffer(capacity = Channel.CONFLATED) + .flowOn(backgroundDispatcher) + } + fun newAccount(): Account { val accountUuid = UUID.randomUUID().toString() val account = Account(accountUuid) @@ -176,27 +190,8 @@ class Preferences internal constructor( notifyAccountsChangeListeners() } - var defaultAccount: Account? - get() { - return getDefaultAccountOrNull() ?: availableAccounts.firstOrNull()?.also { newDefaultAccount -> - defaultAccount = newDefaultAccount - } - } - set(account) { - requireNotNull(account) - - createStorageEditor() - .putString("defaultAccountUuid", account.uuid) - .commit() - } - - private fun getDefaultAccountOrNull(): Account? { - return synchronized(accountLock) { - storage.getString("defaultAccountUuid", null)?.let { defaultAccountUuid -> - getAccount(defaultAccountUuid) - } - } - } + val defaultAccount: Account? + get() = accounts.firstOrNull() fun saveAccount(account: Account) { ensureAssignedAccountNumber(account) diff --git a/app/core/src/main/java/com/fsck/k9/autocrypt/AutocryptOperations.java b/app/core/src/main/java/com/fsck/k9/autocrypt/AutocryptOperations.java index ddece0ad9784731695920dc17727a16b60ce2d54..206733fa5807db3e828b00c9b9758560cf425524 100644 --- a/app/core/src/main/java/com/fsck/k9/autocrypt/AutocryptOperations.java +++ b/app/core/src/main/java/com/fsck/k9/autocrypt/AutocryptOperations.java @@ -5,6 +5,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.List; +import java.util.Locale; import android.content.Intent; import android.os.Bundle; @@ -94,7 +95,8 @@ public class AutocryptOperations { List autocryptGossipHeaders, Date effectiveDate) { Bundle updates = new Bundle(); for (AutocryptGossipHeader autocryptGossipHeader : autocryptGossipHeaders) { - boolean isAcceptedAddress = gossipAcceptedAddresses.contains(autocryptGossipHeader.addr.toLowerCase()); + String normalizedAddress = autocryptGossipHeader.addr.toLowerCase(Locale.ROOT); + boolean isAcceptedAddress = gossipAcceptedAddresses.contains(normalizedAddress); if (!isAcceptedAddress) { continue; } @@ -122,7 +124,7 @@ public class AutocryptOperations { for (Address address : message.getRecipients(recipientType)) { String addr = address.getAddress(); if (addr != null) { - result.add(addr.toLowerCase()); + result.add(addr.toLowerCase(Locale.ROOT)); } } } diff --git a/app/core/src/main/java/com/fsck/k9/controller/KoinModule.kt b/app/core/src/main/java/com/fsck/k9/controller/KoinModule.kt index eedc4b21723e0fbe85dd7c504a2711b07061b61d..f3e94011fbc368b326c5537af49486b671ef5710 100644 --- a/app/core/src/main/java/com/fsck/k9/controller/KoinModule.kt +++ b/app/core/src/main/java/com/fsck/k9/controller/KoinModule.kt @@ -18,7 +18,7 @@ val controllerModule = module { get(), get(), get(), - get(), + get(), get(), get(), get(), @@ -26,5 +26,11 @@ val controllerModule = module { get(named("controllerExtensions")) ) } - single { DefaultUnreadMessageCountProvider(get(), get(), get(), get()) } + single { + DefaultMessageCountsProvider( + preferences = get(), + accountSearchConditions = get(), + localStoreProvider = get() + ) + } } 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/UnreadMessageCountProvider.kt b/app/core/src/main/java/com/fsck/k9/controller/MessageCountsProvider.kt similarity index 50% rename from app/core/src/main/java/com/fsck/k9/controller/UnreadMessageCountProvider.kt rename to app/core/src/main/java/com/fsck/k9/controller/MessageCountsProvider.kt index c2371ff0f1045add04fe4c55a2a9c44c340c83cb..4cfe1e1d590c4b32a8efb508e095bb4ffa43c1cb 100644 --- a/app/core/src/main/java/com/fsck/k9/controller/UnreadMessageCountProvider.kt +++ b/app/core/src/main/java/com/fsck/k9/controller/MessageCountsProvider.kt @@ -1,6 +1,5 @@ package com.fsck.k9.controller -import android.content.Context import com.fsck.k9.Account import com.fsck.k9.Preferences import com.fsck.k9.mail.MessagingException @@ -11,22 +10,19 @@ import com.fsck.k9.search.SearchAccount import com.fsck.k9.search.getAccounts import timber.log.Timber -interface UnreadMessageCountProvider { - fun getUnreadMessageCount(account: Account): Int - fun getUnreadMessageCount(searchAccount: SearchAccount): Int +interface MessageCountsProvider { + fun getMessageCounts(account: Account): MessageCounts + fun getMessageCounts(searchAccount: SearchAccount): MessageCounts } -internal class DefaultUnreadMessageCountProvider( - private val context: Context, +data class MessageCounts(val unread: Int, val starred: Int) + +internal class DefaultMessageCountsProvider( private val preferences: Preferences, private val accountSearchConditions: AccountSearchConditions, private val localStoreProvider: LocalStoreProvider -) : UnreadMessageCountProvider { - override fun getUnreadMessageCount(account: Account): Int { - if (!account.isAvailable(context)) { - return 0 - } - +) : MessageCountsProvider { + override fun getMessageCounts(account: Account): MessageCounts { return try { val localStore = localStoreProvider.getInstance(account) @@ -34,32 +30,35 @@ internal class DefaultUnreadMessageCountProvider( accountSearchConditions.excludeSpecialFolders(account, search) accountSearchConditions.limitToDisplayableFolders(account, search) - localStore.getUnreadMessageCount(search) + localStore.getMessageCounts(search) } catch (e: MessagingException) { - Timber.e(e, "Unable to getUnreadMessageCount for account: %s", account) - 0 + Timber.e(e, "Unable to getMessageCounts for account: %s", account) + MessageCounts(0, 0) } } - override fun getUnreadMessageCount(searchAccount: SearchAccount): Int { + override fun getMessageCounts(searchAccount: SearchAccount): MessageCounts { val search = searchAccount.relatedSearch val accounts = search.getAccounts(preferences) - var unreadMessageCount = 0 + var unreadCount = 0 + var starredCount = 0 for (account in accounts) { - unreadMessageCount += getUnreadMessageCountWithLocalSearch(account, search) + val accountMessageCount = getMessageCountsWithLocalSearch(account, search) + unreadCount += accountMessageCount.unread + starredCount += accountMessageCount.starred } - return unreadMessageCount + return MessageCounts(unreadCount, starredCount) } - private fun getUnreadMessageCountWithLocalSearch(account: Account, search: LocalSearch): Int { + private fun getMessageCountsWithLocalSearch(account: Account, search: LocalSearch): MessageCounts { return try { val localStore = localStoreProvider.getInstance(account) - localStore.getUnreadMessageCount(search) + localStore.getMessageCounts(search) } catch (e: MessagingException) { - Timber.e(e, "Unable to getUnreadMessageCount for account: %s", account) - 0 + Timber.e(e, "Unable to getMessageCounts for account: %s", account) + MessageCounts(0, 0) } } } 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 4993b9b667aec548925b1446210dd074197ff965..8b430664496933eb4e31074cee7546f271d23702 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; @@ -64,6 +63,10 @@ import com.fsck.k9.mail.MessageDownloadState; 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; import com.fsck.k9.mailstore.LocalStore; @@ -75,11 +78,8 @@ import com.fsck.k9.mailstore.OutboxStateRepository; import com.fsck.k9.mailstore.SaveMessageData; import com.fsck.k9.mailstore.SaveMessageDataCreator; import com.fsck.k9.mailstore.SendState; -import com.fsck.k9.mailstore.UnavailableStorageException; 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; @@ -125,8 +125,9 @@ public class MessagingController { private final Set listeners = new CopyOnWriteArraySet<>(); private final ExecutorService threadPool = Executors.newCachedThreadPool(); private final MemorizingMessagingListener memorizingMessagingListener = new MemorizingMessagingListener(); - private final UnreadMessageCountProvider unreadMessageCountProvider; + private final MessageCountsProvider messageCountsProvider; private final DraftOperations draftOperations; + private final NotificationOperations notificationOperations; private MessagingListener checkMailListener = null; @@ -140,14 +141,14 @@ public class MessagingController { MessagingController(Context context, NotificationController notificationController, NotificationStrategy notificationStrategy, LocalStoreProvider localStoreProvider, - UnreadMessageCountProvider unreadMessageCountProvider, BackendManager backendManager, + MessageCountsProvider messageCountsProvider, BackendManager backendManager, Preferences preferences, MessageStoreManager messageStoreManager, SaveMessageDataCreator saveMessageDataCreator, List controllerExtensions) { this.context = context; this.notificationController = notificationController; this.notificationStrategy = notificationStrategy; this.localStoreProvider = localStoreProvider; - this.unreadMessageCountProvider = unreadMessageCountProvider; + this.messageCountsProvider = messageCountsProvider; this.backendManager = backendManager; this.preferences = preferences; this.messageStoreManager = messageStoreManager; @@ -166,6 +167,7 @@ public class MessagingController { initializeControllerExtensions(controllerExtensions); draftOperations = new DraftOperations(this, messageStoreManager, saveMessageDataCreator); + notificationOperations = new NotificationOperations(notificationController, preferences, messageStoreManager); } private void initializeControllerExtensions(List controllerExtensions) { @@ -214,23 +216,7 @@ public class MessagingController { command.sequence, command.isForegroundPriority ? "foreground" : "background"); - try { - command.runnable.run(); - } catch (UnavailableAccountException e) { - // retry later - new Thread() { - @Override - public void run() { - try { - sleep(30 * 1000); - queuedCommands.put(command); - } catch (InterruptedException e) { - Timber.e("Interrupted while putting a pending command for an unavailable account " + - "back into the queue. THIS SHOULD NEVER HAPPEN."); - } - } - }.start(); - } + command.runnable.run(); Timber.i(" Command '%s' completed", command.description); } @@ -281,23 +267,22 @@ public class MessagingController { } } - private String getFolderServerId(Account account, long folderId) throws MessagingException { - LocalStore localStore = getLocalStoreOrThrow(account); - return localStore.getFolderServerId(folderId); - } - - private long getFolderId(Account account, String folderServerId) throws MessagingException { - LocalStore localStore = getLocalStoreOrThrow(account); - return localStore.getFolderId(folderServerId); + 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 getFolderIdOrThrow(Account account, String folderServerId) { - LocalStore localStore = getLocalStoreOrThrow(account); - try { - return localStore.getFolderId(folderServerId); - } catch (MessagingException e) { - throw new IllegalStateException(e); + private long getFolderId(Account account, String folderServerId) { + MessageStore messageStore = messageStoreManager.getMessageStore(account); + Long folderId = messageStore.getFolderId(folderServerId); + if (folderId == null) { + throw new IllegalStateException("Folder not found (server ID: " + folderServerId + ")"); } + return folderId; } public void addListener(MessagingListener listener) { @@ -390,6 +375,12 @@ public class MessagingController { public void refreshFolderListSynchronous(Account account) { try { + ServerSettings serverSettings = account.getIncomingServerSettings(); + if (serverSettings.isMissingCredentials()) { + handleAuthenticationFailure(account, true); + return; + } + Backend backend = getBackend(account); backend.refreshFolderList(); @@ -591,17 +582,17 @@ public class MessagingController { */ public void synchronizeMailbox(Account account, long folderId, MessagingListener listener) { putBackground("synchronizeMailbox", listener, () -> - synchronizeMailboxSynchronous(account, folderId, listener) + synchronizeMailboxSynchronous(account, folderId, listener, new NotificationState()) ); } - public void synchronizeMailboxBlocking(Account account, String folderServerId) throws MessagingException { + public void synchronizeMailboxBlocking(Account account, String folderServerId) { long folderId = getFolderId(account, folderServerId); final CountDownLatch latch = new CountDownLatch(1); putBackground("synchronizeMailbox", null, () -> { try { - synchronizeMailboxSynchronous(account, folderId, null); + synchronizeMailboxSynchronous(account, folderId, null, new NotificationState()); } finally { latch.countDown(); } @@ -621,11 +612,12 @@ public class MessagingController { * TODO Break this method up into smaller chunks. */ @VisibleForTesting - void synchronizeMailboxSynchronous(Account account, long folderId, MessagingListener listener) { + void synchronizeMailboxSynchronous(Account account, long folderId, MessagingListener listener, + NotificationState notificationState) { refreshFolderListIfStale(account); Backend backend = getBackend(account); - syncFolder(account, folderId, listener, backend); + syncFolder(account, folderId, listener, backend, notificationState); } private void refreshFolderListIfStale(Account account) { @@ -640,7 +632,14 @@ public class MessagingController { } } - private void syncFolder(Account account, long folderId, MessagingListener listener, Backend backend) { + private void syncFolder(Account account, long folderId, MessagingListener listener, Backend backend, + NotificationState notificationState) { + ServerSettings serverSettings = account.getIncomingServerSettings(); + if (serverSettings.isMissingCredentials()) { + handleAuthenticationFailure(account, true); + return; + } + Exception commandException = null; try { processPendingCommandsSynchronous(account); @@ -664,9 +663,14 @@ public class MessagingController { 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); + ControllerSyncListener syncListener = + new ControllerSyncListener(account, listener, suppressNotifications, notificationState); backend.sync(folderServerId, syncConfig, syncListener); @@ -725,10 +729,6 @@ public class MessagingController { public void run() { try { processPendingCommandsSynchronous(account); - } catch (UnavailableStorageException e) { - Timber.i("Failed to process pending command because storage is not available - " + - "trying again later."); - throw new UnavailableAccountException(e); } catch (MessagingException me) { Timber.e(me, "processPendingCommands"); @@ -1318,7 +1318,7 @@ public class MessagingController { localFolder.fetch(Collections.singletonList(message), fp, null); notificationController.removeNewMailNotification(account, message.makeMessageReference()); - markMessageAsReadOnView(account, message); + markMessageAsOpened(account, message); return message; } @@ -1341,15 +1341,39 @@ public class MessagingController { return message; } - private void markMessageAsReadOnView(Account account, LocalMessage message) - throws MessagingException { + private void markMessageAsOpened(Account account, LocalMessage message) throws MessagingException { + if (!message.isSet(Flag.SEEN)) { + if (account.isMarkMessageAsReadOnView()) { + markMessageAsReadOnView(account, message); + } else { + // Marking a message as read will automatically mark it as "not new". But if we don't mark the message + // as read on opening, we have to manually mark it as "not new". + markMessageAsNotNew(account, message); + } + } + } - if (account.isMarkMessageAsReadOnView() && !message.isSet(Flag.SEEN)) { - List messageIds = Collections.singletonList(message.getDatabaseId()); - setFlag(account, messageIds, Flag.SEEN, true); + private void markMessageAsReadOnView(Account account, LocalMessage message) throws MessagingException { + List messageIds = Collections.singletonList(message.getDatabaseId()); + setFlag(account, messageIds, Flag.SEEN, true); - message.setFlagInternal(Flag.SEEN, true); - } + message.setFlagInternal(Flag.SEEN, true); + } + + private void markMessageAsNotNew(Account account, LocalMessage message) { + MessageStore messageStore = messageStoreManager.getMessageStore(account); + long folderId = message.getFolder().getDatabaseId(); + String messageServerId = message.getUid(); + messageStore.setNewMessageState(folderId, messageServerId, false); + } + + public void clearNewMessages(Account account) { + put("clearNewMessages", null, () -> clearNewMessagesBlocking(account)); + } + + private void clearNewMessagesBlocking(Account account) { + MessageStore messageStore = messageStoreManager.getMessageStore(account); + messageStore.clearNewMessageState(); } public void loadAttachment(final Account account, final LocalMessage message, final Part part, @@ -1426,13 +1450,6 @@ public class MessagingController { backend.sendMessage(message); } - public void sendPendingMessages(MessagingListener listener) { - for (Account account : preferences.getAvailableAccounts()) { - sendPendingMessages(account, listener); - } - } - - /** * Attempt to send any messages that are sitting in the Outbox. */ @@ -1441,9 +1458,6 @@ public class MessagingController { putBackground("sendPendingMessages", listener, new Runnable() { @Override public void run() { - if (!account.isAvailable(context)) { - throw new UnavailableAccountException(); - } if (messagesPendingSend(account)) { showSendingNotificationIfNecessary(account); @@ -1471,21 +1485,14 @@ public class MessagingController { } private boolean messagesPendingSend(final Account account) { - try { - LocalFolder localFolder = localStoreProvider.getInstance(account).getFolder(account.getOutboxFolderId()); - if (!localFolder.exists()) { - return false; - } - - localFolder.open(); - - if (localFolder.getMessageCount() > 0) { - return true; - } - } catch (Exception e) { - Timber.e(e, "Exception while checking for unsent messages"); + Long outboxFolderId = account.getOutboxFolderId(); + if (outboxFolderId == null) { + Timber.w("Could not get Outbox folder ID from Account"); + return false; } - return false; + + MessageStore messageStore = messageStoreManager.getMessageStore(account); + return messageStore.getMessageCount(outboxFolderId) > 0; } /** @@ -1496,6 +1503,12 @@ public class MessagingController { Exception lastFailure = null; boolean wasPermanentFailure = false; try { + ServerSettings serverSettings = account.getOutgoingServerSettings(); + if (serverSettings.isMissingCredentials()) { + handleAuthenticationFailure(account, false); + return; + } + LocalStore localStore = localStoreProvider.getInstance(account); OutboxStateRepository outboxStateRepository = localStore.getOutboxStateRepository(); LocalFolder localFolder = localStore.getFolder(account.getOutboxFolderId()); @@ -1539,7 +1552,7 @@ public class MessagingController { OutboxState outboxState = outboxStateRepository.getOutboxState(messageId); if (outboxState.getSendState() != SendState.READY) { - Timber.v("Skipping sending message " + message.getUid()); + Timber.v("Skipping sending message %s", message.getUid()); notificationController.showSendFailedNotification(account, new MessagingException(message.getSubject())); continue; @@ -1622,9 +1635,6 @@ public class MessagingController { notificationController.showSendFailedNotification(account, lastFailure); } } - } catch (UnavailableStorageException e) { - Timber.i("Failed to send pending messages because storage is not available - trying again later."); - throw new UnavailableAccountException(e); } catch (Exception e) { Timber.v(e, "Failed to send pending messages"); } finally { @@ -1658,6 +1668,10 @@ public class MessagingController { processPendingCommands(account); } } + + for (MessagingListener listener : getListeners()) { + listener.folderStatusChanged(account, account.getOutboxFolderId()); + } } private void handleSendFailure(Account account, LocalFolder localFolder, Message message, Exception exception) @@ -1678,11 +1692,13 @@ public class MessagingController { } public int getUnreadMessageCount(Account account) { - return unreadMessageCountProvider.getUnreadMessageCount(account); + MessageCounts messageCounts = messageCountsProvider.getMessageCounts(account); + return messageCounts.getUnread(); } public int getUnreadMessageCount(SearchAccount searchAccount) { - return unreadMessageCountProvider.getUnreadMessageCount(searchAccount); + MessageCounts messageCounts = messageCountsProvider.getMessageCounts(searchAccount); + return messageCounts.getUnread(); } public int getFolderUnreadMessageCount(Account account, Long folderId) throws MessagingException { @@ -1888,9 +1904,6 @@ public class MessagingController { } processPendingCommands(account); - } catch (UnavailableStorageException e) { - Timber.i("Failed to move/copy message because storage is not available - trying again later."); - throw new UnavailableAccountException(e); } catch (MessagingException me) { throw new RuntimeException("Error moving message", me); } @@ -1940,7 +1953,7 @@ public class MessagingController { 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) { @@ -2135,9 +2148,6 @@ public class MessagingController { } unsuppressMessages(account, messages); - } catch (UnavailableStorageException e) { - Timber.i("Failed to delete message because storage is not available - trying again later."); - throw new UnavailableAccountException(e); } catch (MessagingException me) { throw new RuntimeException("Error deleting message from local store.", me); } @@ -2207,9 +2217,6 @@ public class MessagingController { queuePendingCommand(account, command); processPendingCommands(account); } - } catch (UnavailableStorageException e) { - Timber.i("Failed to empty trash because storage is not available - trying again later."); - throw new UnavailableAccountException(e); } catch (Exception e) { Timber.e(e, "emptyTrash failed"); } @@ -2229,9 +2236,6 @@ public class MessagingController { LocalFolder localFolder = localStoreProvider.getInstance(account).getFolder(folderId); localFolder.open(); localFolder.clearAllMessages(); - } catch (UnavailableStorageException e) { - Timber.i("Failed to clear folder because storage is not available - trying again later."); - throw new UnavailableAccountException(e); } catch (Exception e) { Timber.e(e, "clearFolder failed"); } @@ -2298,15 +2302,16 @@ public class MessagingController { final boolean useManualWakeLock, final MessagingListener listener) { - TracingWakeLock twakeLock = null; + final WakeLock wakeLock; if (useManualWakeLock) { - TracingPowerManager pm = TracingPowerManager.getPowerManager(context); + PowerManager pm = DI.get(PowerManager.class); - twakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "K9 MessagingController.checkMail"); - twakeLock.setReferenceCounted(false); - twakeLock.acquire(K9.MANUAL_WAKE_LOCK_TIMEOUT); + 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); @@ -2323,7 +2328,7 @@ public class MessagingController { accounts = new ArrayList<>(1); accounts.add(account); } else { - accounts = preferences.getAvailableAccounts(); + accounts = preferences.getAccounts(); } for (final Account account : accounts) { @@ -2357,14 +2362,9 @@ public class MessagingController { private void checkMailForAccount(final Context context, final Account account, final boolean ignoreLastCheckedTime, final MessagingListener listener) { - if (!account.isAvailable(context)) { - Timber.i("Skipping synchronizing unavailable account %s", account.getDescription()); - return; - } - Timber.i("Synchronizing account %s", account.getDescription()); - account.setRingNotified(false); + NotificationState notificationState = new NotificationState(); sendPendingMessages(account, listener); @@ -2404,7 +2404,7 @@ public class MessagingController { continue; } - synchronizeFolder(account, folder, ignoreLastCheckedTime, listener); + synchronizeFolder(account, folder, ignoreLastCheckedTime, listener, notificationState); } } catch (MessagingException e) { Timber.e(e, "Unable to synchronize account %s", account.getName()); @@ -2414,9 +2414,10 @@ public class MessagingController { public void run() { Timber.v("Clearing notification flag for %s", account.getDescription()); - account.setRingNotified(false); + clearFetchingMailNotification(account); + if (getUnreadMessageCount(account) == 0) { - notificationController.clearNewMailNotifications(account); + notificationController.clearNewMailNotifications(account, false); } } } @@ -2427,14 +2428,14 @@ public class MessagingController { } private void synchronizeFolder(Account account, LocalFolder folder, boolean ignoreLastCheckedTime, - MessagingListener listener) { + MessagingListener listener, NotificationState notificationState) { putBackground("sync" + folder.getServerId(), null, () -> { - synchronizeFolderInBackground(account, folder, ignoreLastCheckedTime, listener); + synchronizeFolderInBackground(account, folder, ignoreLastCheckedTime, listener, notificationState); }); } private void synchronizeFolderInBackground(Account account, LocalFolder folder, boolean ignoreLastCheckedTime, - MessagingListener listener) { + MessagingListener listener, NotificationState notificationState) { Timber.v("Folder %s was last synced @ %tc", folder.getServerId(), folder.getLastChecked()); if (!ignoreLastCheckedTime) { @@ -2457,12 +2458,9 @@ public class MessagingController { try { showFetchingMailNotificationIfNecessary(account, folder); try { - synchronizeMailboxSynchronous(account, folder.getDatabaseId(), listener); - - long now = System.currentTimeMillis(); - folder.setLastChecked(now); + synchronizeMailboxSynchronous(account, folder.getDatabaseId(), listener, notificationState); } finally { - clearFetchingMailNotificationIfNecessary(account); + showEmptyFetchingMailNotificationIfNecessary(account); } } catch (Exception e) { Timber.e(e, "Exception while processing folder %s:%s", account.getDescription(), folder.getServerId()); @@ -2475,28 +2473,28 @@ public class MessagingController { } } - private void clearFetchingMailNotificationIfNecessary(Account account) { + private void showEmptyFetchingMailNotificationIfNecessary(Account account) { if (account.isNotifySync()) { - notificationController.clearFetchingMailNotification(account); + notificationController.showEmptyFetchingMailNotification(account); } } + private void clearFetchingMailNotification(Account account) { + notificationController.clearFetchingMailNotification(account); + } public void compact(final Account account, final MessagingListener ml) { putBackground("compact:" + account.getDescription(), ml, new Runnable() { @Override public void run() { try { - LocalStore localStore = localStoreProvider.getInstance(account); - long oldSize = localStore.getSize(); - localStore.compact(); - long newSize = localStore.getSize(); + MessageStore messageStore = messageStoreManager.getMessageStore(account); + long oldSize = messageStore.getSize(); + messageStore.compact(); + long newSize = messageStore.getSize(); for (MessagingListener l : getListeners(ml)) { l.accountSizeChanged(account, oldSize, newSize); } - } catch (UnavailableStorageException e) { - Timber.i("Failed to compact account because storage is not available - trying again later."); - throw new UnavailableAccountException(e); } catch (Exception e) { Timber.e(e, "Failed to compact account %s", account.getDescription()); } @@ -2504,54 +2502,8 @@ public class MessagingController { }); } - public void clear(final Account account, final MessagingListener ml) { - putBackground("clear:" + account.getDescription(), ml, new Runnable() { - @Override - public void run() { - try { - LocalStore localStore = localStoreProvider.getInstance(account); - long oldSize = localStore.getSize(); - localStore.clear(); - localStore.resetVisibleLimits(account.getDisplayCount()); - long newSize = localStore.getSize(); - for (MessagingListener l : getListeners(ml)) { - l.accountSizeChanged(account, oldSize, newSize); - } - } catch (UnavailableStorageException e) { - Timber.i("Failed to clear account because storage is not available - trying again later."); - throw new UnavailableAccountException(e); - } catch (Exception e) { - Timber.e(e, "Failed to clear account %s", account.getDescription()); - } - } - }); - } - - public void recreate(final Account account, final MessagingListener ml) { - putBackground("recreate:" + account.getDescription(), ml, new Runnable() { - @Override - public void run() { - try { - LocalStore localStore = localStoreProvider.getInstance(account); - long oldSize = localStore.getSize(); - localStore.recreate(); - localStore.resetVisibleLimits(account.getDisplayCount()); - long newSize = localStore.getSize(); - for (MessagingListener l : getListeners(ml)) { - l.accountSizeChanged(account, oldSize, newSize); - } - } catch (UnavailableStorageException e) { - Timber.i("Failed to recreate an account because storage is not available - trying again later."); - throw new UnavailableAccountException(e); - } catch (Exception e) { - Timber.e(e, "Failed to recreate account %s", account.getDescription()); - } - } - }); - } - public void deleteAccount(Account account) { - notificationController.clearNewMailNotifications(account); + notificationController.clearNewMailNotifications(account, false); memorizingMessagingListener.removeAccount(account); } @@ -2607,8 +2559,14 @@ public class MessagingController { } } + public void clearNotifications(LocalSearch search) { + put("clearNotifications", null, () -> { + notificationOperations.clearNotifications(search); + }); + } + public void cancelNotificationsForAccount(Account account) { - notificationController.clearNewMailNotifications(account); + notificationController.clearNewMailNotifications(account, true); } public void cancelNotificationForMessage(Account account, MessageReference messageReference) { @@ -2696,21 +2654,23 @@ 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) { + ControllerSyncListener(Account account, MessagingListener listener, boolean suppressNotifications, + NotificationState notificationState) { this.account = account; this.listener = listener; + this.suppressNotifications = suppressNotifications; + this.notificationState = notificationState; this.localStore = getLocalStoreOrThrow(account); - - previousUnreadMessageCount = getUnreadMessageCount(account); } @Override public void syncStarted(@NotNull String folderServerId) { - long folderId = getFolderIdOrThrow(account, folderServerId); + long folderId = getFolderId(account, folderServerId); for (MessagingListener messagingListener : getListeners(listener)) { messagingListener.synchronizeMailboxStarted(account, folderId); } @@ -2746,7 +2706,7 @@ public class MessagingController { @Override public void syncProgress(@NotNull String folderServerId, int completed, int total) { - long folderId = getFolderIdOrThrow(account, folderServerId); + long folderId = getFolderId(account, folderServerId); for (MessagingListener messagingListener : getListeners(listener)) { messagingListener.synchronizeMailboxProgress(account, folderId, completed, total); } @@ -2759,10 +2719,13 @@ public class MessagingController { // Send a notification of this message LocalMessage message = loadMessage(folderServerId, messageServerId); LocalFolder localFolder = message.getFolder(); - if (notificationStrategy.shouldNotifyForMessage(account, localFolder, message, isOldMessage)) { + if (!suppressNotifications && + notificationStrategy.shouldNotifyForMessage(account, localFolder, message, isOldMessage)) { 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. - notificationController.addNewMailNotification(account, message, previousUnreadMessageCount); + boolean silent = notificationState.wasNotified(); + notificationController.addNewMailNotification(account, message, silent); + notificationState.setWasNotified(true); } if (!message.isSet(Flag.SEEN)) { @@ -2777,6 +2740,11 @@ 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); + notificationController.removeNewMailNotification(account, messageReference); } @Override @@ -2801,7 +2769,7 @@ public class MessagingController { @Override public void syncFinished(@NotNull String folderServerId) { - long folderId = getFolderIdOrThrow(account, folderServerId); + long folderId = getFolderId(account, folderServerId); for (MessagingListener messagingListener : getListeners(listener)) { messagingListener.synchronizeMailboxFinished(account, folderId); } @@ -2817,7 +2785,7 @@ public class MessagingController { notifyUserIfCertificateProblem(account, exception, true); } - long folderId = getFolderIdOrThrow(account, folderServerId); + long folderId = getFolderId(account, folderServerId); for (MessagingListener messagingListener : getListeners(listener)) { messagingListener.synchronizeMailboxFailed(account, folderId, message); } @@ -2825,7 +2793,7 @@ public class MessagingController { @Override public void folderStatusChanged(@NotNull String folderServerId) { - long folderId = getFolderIdOrThrow(account, folderServerId); + long folderId = getFolderId(account, folderServerId); for (MessagingListener messagingListener : getListeners(listener)) { messagingListener.folderStatusChanged(account, folderId); } diff --git a/app/core/src/main/java/com/fsck/k9/controller/NotificationOperations.kt b/app/core/src/main/java/com/fsck/k9/controller/NotificationOperations.kt new file mode 100644 index 0000000000000000000000000000000000000000..8ab0a7f448536edeb47e6aa9185a5f6efc1ae89b --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/controller/NotificationOperations.kt @@ -0,0 +1,63 @@ +package com.fsck.k9.controller + +import com.fsck.k9.Account +import com.fsck.k9.Preferences +import com.fsck.k9.mailstore.MessageStoreManager +import com.fsck.k9.notification.NotificationController +import com.fsck.k9.search.LocalSearch +import com.fsck.k9.search.isNewMessages +import com.fsck.k9.search.isSingleFolder +import com.fsck.k9.search.isUnifiedInbox + +internal class NotificationOperations( + private val notificationController: NotificationController, + private val preferences: Preferences, + private val messageStoreManager: MessageStoreManager +) { + fun clearNotifications(search: LocalSearch) { + if (search.isUnifiedInbox) { + clearUnifiedInboxNotifications() + } else if (search.isNewMessages) { + clearAllNotifications() + } else if (search.isSingleFolder) { + val account = search.firstAccount() ?: return + val folderId = search.folderIds.first() + clearNotifications(account, folderId) + } else { + // TODO: Remove notifications when updating the message list. That way we can easily remove only + // notifications for messages that are currently displayed in the list. + } + } + + private fun clearUnifiedInboxNotifications() { + for (account in preferences.accounts) { + val messageStore = messageStoreManager.getMessageStore(account) + + val folderIds = messageStore.getFolders(excludeLocalOnly = true) { folderDetails -> + if (folderDetails.isIntegrate) folderDetails.id else null + }.filterNotNull().toSet() + + if (folderIds.isNotEmpty()) { + notificationController.clearNewMailNotifications(account) { messageReferences -> + messageReferences.filter { messageReference -> messageReference.folderId in folderIds } + } + } + } + } + + private fun clearAllNotifications() { + for (account in preferences.accounts) { + notificationController.clearNewMailNotifications(account, clearNewMessageState = false) + } + } + + private fun clearNotifications(account: Account, folderId: Long) { + notificationController.clearNewMailNotifications(account) { messageReferences -> + messageReferences.filter { messageReference -> messageReference.folderId == folderId } + } + } + + private fun LocalSearch.firstAccount(): Account? { + return preferences.getAccount(accountUuids.first()) + } +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..32306f5b25379b6df311dd7cdb8dd87967239b31 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/controller/NotificationState.kt @@ -0,0 +1,6 @@ +package com.fsck.k9.controller + +class NotificationState { + @get:JvmName("wasNotified") + var wasNotified: Boolean = false +} 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/UnavailableAccountException.java b/app/core/src/main/java/com/fsck/k9/controller/UnavailableAccountException.java deleted file mode 100644 index 55533828eb18d9cf82f73861f33b18458312dfae..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/controller/UnavailableAccountException.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.fsck.k9.controller; - -/** - * An {@link com.fsck.k9.Account} is not - * {@link com.fsck.k9.Account#isAvailable(android.content.Context)}.
- * The operation may be retried later. - */ -public class UnavailableAccountException extends RuntimeException { - - /** - * - */ - private static final long serialVersionUID = -1827283277120501465L; - - public UnavailableAccountException() { - super("please try again later"); - } - - /** - * @param detailMessage - * @param throwable - */ - public UnavailableAccountException(String detailMessage, Throwable throwable) { - super(detailMessage, throwable); - } - - /** - * @param detailMessage - */ - public UnavailableAccountException(String detailMessage) { - super(detailMessage); - } - - /** - * @param throwable - */ - public UnavailableAccountException(Throwable throwable) { - super(throwable); - } -} 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 a3df472cafbd4485b19c63fa21f88bb892280cdf..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 @@ -1,11 +1,13 @@ package com.fsck.k9.controller.push import com.fsck.k9.Account +import com.fsck.k9.Account.FolderMode +import com.fsck.k9.Preferences 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 @@ -17,11 +19,11 @@ import timber.log.Timber internal class AccountPushController( private val backendManager: BackendManager, private val messagingController: MessagingController, - folderRepositoryManager: FolderRepositoryManager, + private val preferences: Preferences, + private val folderRepository: FolderRepository, backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO, private val account: Account ) { - private val folderRepository = folderRepositoryManager.getFolderRepository(account) private val coroutineScope = CoroutineScope(backgroundDispatcher) @Volatile @@ -35,6 +37,11 @@ internal class AccountPushController( override fun onPushError(exception: Exception) { messagingController.handleException(account, exception) } + + override fun onPushNotSupported() { + Timber.v("AccountPushController(%s) - Push not supported. Disabling Push for account.", account.uuid) + disablePush() + } } fun start() { @@ -68,7 +75,7 @@ internal class AccountPushController( private fun startListeningForPushFolders() { coroutineScope.launch { - folderRepository.getPushFoldersFlow().collect { remoteFolders -> + folderRepository.getPushFoldersFlow(account).collect { remoteFolders -> val folderServerIds = remoteFolders.map { it.serverId } updatePushFolders(folderServerIds) } @@ -88,4 +95,9 @@ internal class AccountPushController( private fun syncFolders(folderServerId: String) { messagingController.synchronizeMailboxBlocking(account, folderServerId) } + + private fun disablePush() { + account.folderPushMode = FolderMode.NONE + preferences.saveAccount(account) + } } 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 9a083a45e8702cae48d057e1a99cea439442b5eb..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,16 +1,24 @@ 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.mailstore.FolderRepositoryManager +import com.fsck.k9.mailstore.FolderRepository internal class AccountPushControllerFactory( private val backendManager: BackendManager, private val messagingController: MessagingController, - private val folderRepositoryManager: FolderRepositoryManager + private val folderRepository: FolderRepository, + private val preferences: Preferences ) { fun create(account: Account): AccountPushController { - return AccountPushController(backendManager, messagingController, folderRepositoryManager, account = account) + return AccountPushController( + backendManager, + messagingController, + preferences, + folderRepository, + account = account + ) } } diff --git a/app/core/src/main/java/com/fsck/k9/controller/push/KoinModule.kt b/app/core/src/main/java/com/fsck/k9/controller/push/KoinModule.kt index 146eec236a1ceef899f33416dfa5d2e06df47e94..b8046a970d17294f86a7eb44f5c583121a8a95d3 100644 --- a/app/core/src/main/java/com/fsck/k9/controller/push/KoinModule.kt +++ b/app/core/src/main/java/com/fsck/k9/controller/push/KoinModule.kt @@ -10,7 +10,8 @@ internal val controllerPushModule = module { AccountPushControllerFactory( backendManager = get(), messagingController = get(), - folderRepositoryManager = get() + folderRepository = get(), + preferences = get() ) } single { diff --git a/app/core/src/main/java/com/fsck/k9/helper/CallbackFlowHelper.kt b/app/core/src/main/java/com/fsck/k9/helper/CallbackFlowHelper.kt new file mode 100644 index 0000000000000000000000000000000000000000..c04866115888f3788140c839bb129eba414e34a4 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/helper/CallbackFlowHelper.kt @@ -0,0 +1,16 @@ +package com.fsck.k9.helper + +import kotlinx.coroutines.channels.ClosedSendChannelException +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.channels.sendBlocking + +/** + * Like [sendBlocking], but ignores [ClosedSendChannelException]. + */ +fun SendChannel.sendBlockingSilently(element: E) { + try { + sendBlocking(element) + } catch (e: ClosedSendChannelException) { + // Ignore + } +} diff --git a/app/core/src/main/java/com/fsck/k9/helper/CursorExtensions.kt b/app/core/src/main/java/com/fsck/k9/helper/CursorExtensions.kt index dcd348b7bf94471a95d4925f3b86f6429b35c5c2..61c60c7ba82299853f364239f771c23ab7462413 100644 --- a/app/core/src/main/java/com/fsck/k9/helper/CursorExtensions.kt +++ b/app/core/src/main/java/com/fsck/k9/helper/CursorExtensions.kt @@ -23,3 +23,15 @@ fun Cursor.getLongOrNull(columnName: String): Long? { val columnIndex = getColumnIndex(columnName) return if (isNull(columnIndex)) null else getLong(columnIndex) } + +fun Cursor.getStringOrThrow(columnName: String): String { + return getStringOrNull(columnName) ?: error("Column $columnName must not be null") +} + +fun Cursor.getIntOrThrow(columnName: String): Int { + return getIntOrNull(columnName) ?: error("Column $columnName must not be null") +} + +fun Cursor.getLongOrThrow(columnName: String): Long { + return getLongOrNull(columnName) ?: error("Column $columnName must not be null") +} 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 5595d47f86db25c3363729ee009e70f057dcdc02..cb9e865dad8414b646010f883d4965780bbf6710 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 @@ -1,6 +1,8 @@ package com.fsck.k9.helper; +import java.util.regex.Pattern; + import android.content.Context; import android.text.Spannable; import android.text.SpannableString; @@ -27,6 +29,7 @@ public class MessageHelper { * @see #toFriendly(Address[], com.fsck.k9.helper.Contacts) */ private static final int TOO_MANY_ADDRESSES = 50; + private static final Pattern SPOOF_ADDRESS_PATTERN = Pattern.compile("[^(]@"); private static MessageHelper sInstance; @@ -143,6 +146,6 @@ public class MessageHelper { } private static boolean isSpoofAddress(String displayName) { - return displayName.contains("@"); + return displayName.contains("@") && SPOOF_ADDRESS_PATTERN.matcher(displayName).find(); } } diff --git a/app/core/src/main/java/com/fsck/k9/job/K9JobManager.kt b/app/core/src/main/java/com/fsck/k9/job/K9JobManager.kt index d511c83cb6644398a0c9593940033e3fb5bd64fd..227909e9425b11d777ceaeba799ad6fa3ac791b3 100644 --- a/app/core/src/main/java/com/fsck/k9/job/K9JobManager.kt +++ b/app/core/src/main/java/com/fsck/k9/job/K9JobManager.kt @@ -23,7 +23,7 @@ class K9JobManager( private fun scheduleMailSync() { cancelAllMailSyncJobs() - preferences.availableAccounts.forEach { account -> + preferences.accounts.forEach { account -> mailSyncWorkerManager.scheduleMailSync(account) } } 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 bb1237c1038cb54a81c190fba88a91714cf7c349..193c24a5ccc6c2eb8d86b8ba6a47dd43bcd551a2 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 @@ -39,6 +39,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/AutoExpandFolderBackendFoldersRefreshListener.kt b/app/core/src/main/java/com/fsck/k9/mailstore/AutoExpandFolderBackendFoldersRefreshListener.kt index e7394c8b9afc9b4ec208f2666cfca434df792de4..f709798876940cd6947ca3af3df5d1d5a09e431f 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/AutoExpandFolderBackendFoldersRefreshListener.kt +++ b/app/core/src/main/java/com/fsck/k9/mailstore/AutoExpandFolderBackendFoldersRefreshListener.kt @@ -11,8 +11,11 @@ class AutoExpandFolderBackendFoldersRefreshListener( private val account: Account, private val folderRepository: FolderRepository ) : BackendFoldersRefreshListener { + private var isFirstSync = false - override fun onBeforeFolderListRefresh() = Unit + override fun onBeforeFolderListRefresh() { + isFirstSync = account.inboxFolderId == null + } override fun onAfterFolderListRefresh() { checkAutoExpandFolder() @@ -22,17 +25,25 @@ class AutoExpandFolderBackendFoldersRefreshListener( } private fun checkAutoExpandFolder() { - val folderId = account.importedAutoExpandFolder?.let { folderRepository.getFolderId(it) } - if (folderId != null) { - account.autoExpandFolderId = folderId + account.importedAutoExpandFolder?.let { folderName -> + if (folderName.isEmpty()) { + account.autoExpandFolderId = null + } else { + val folderId = folderRepository.getFolderId(account, folderName) + account.autoExpandFolderId = folderId + } return } account.autoExpandFolderId?.let { autoExpandFolderId -> - if (!folderRepository.isFolderPresent(autoExpandFolderId)) { + if (!folderRepository.isFolderPresent(account, autoExpandFolderId)) { account.autoExpandFolderId = null } } + + if (isFirstSync && account.autoExpandFolderId == null) { + account.autoExpandFolderId = account.inboxFolderId + } } private fun removeImportedAutoExpandFolder() { diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/FolderMapper.kt b/app/core/src/main/java/com/fsck/k9/mailstore/FolderMapper.kt index f283618e82d9f9413da0e1248f5724863fdf8667..8cea58d994c549149deb9dc1f7ec61a358124e58 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/FolderMapper.kt +++ b/app/core/src/main/java/com/fsck/k9/mailstore/FolderMapper.kt @@ -21,7 +21,9 @@ interface FolderDetailsAccessor { val pushClass: FolderClass val visibleLimit: Int val moreMessages: MoreMessages - val messageCount: Int + val lastChecked: Long? + val unreadMessageCount: Int + val starredMessageCount: Int fun serverIdOrThrow(): String } 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 d211dbb52c02f8206d3f5bbb7d488510e733d9c4..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 @@ -16,14 +20,12 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch import com.fsck.k9.mail.FolderType as RemoteFolderType @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 = @@ -33,7 +35,7 @@ class FolderRepository( .thenByDescending { it.isInTopGroup } .thenBy(String.CASE_INSENSITIVE_ORDER) { it.folder.name } - fun getDisplayFolders(displayMode: FolderMode?): List { + fun getDisplayFolders(account: Account, displayMode: FolderMode?): List { val messageStore = messageStoreManager.getMessageStore(account) return messageStore.getDisplayFolders( displayMode = displayMode ?: account.folderDisplayMode, @@ -43,35 +45,77 @@ class FolderRepository( folder = Folder( id = folder.id, name = folder.name, - type = folderTypeOf(folder.id), + type = folderTypeOf(account, folder.id), isLocalOnly = folder.isLocalOnly ), isInTopGroup = folder.isInTopGroup, - unreadCount = folder.messageCount + unreadMessageCount = folder.unreadMessageCount, + starredMessageCount = folder.starredMessageCount ) }.sortedWith(sortForDisplay) } - fun getFolder(folderId: Long): Folder? { + fun getDisplayFoldersFlow(account: Account, displayMode: FolderMode): Flow> { + val messagingController = DI.get() + val messageStore = messageStoreManager.getMessageStore(account) + + return callbackFlow { + send(getDisplayFolders(account, displayMode)) + + val folderStatusChangedListener = object : SimpleMessagingListener() { + override fun folderStatusChanged(statusChangedAccount: Account, folderId: Long) { + if (statusChangedAccount.uuid == account.uuid) { + sendBlockingSilently(getDisplayFolders(account, displayMode)) + } + } + } + messagingController.addListener(folderStatusChangedListener) + + val folderSettingsChangedListener = FolderSettingsChangedListener { + sendBlockingSilently(getDisplayFolders(account, displayMode)) + } + messageStore.addFolderSettingsChangedListener(folderSettingsChangedListener) + + awaitClose { + messagingController.removeListener(folderStatusChangedListener) + messageStore.removeFolderSettingsChangedListener(folderSettingsChangedListener) + } + }.buffer(capacity = Channel.CONFLATED) + .distinctUntilChanged() + .flowOn(ioDispatcher) + } + + fun getDisplayFoldersFlow(account: Account): Flow> { + return accountManager.getAccountFlow(account.uuid) + .map { latestAccount -> + AccountContainer(latestAccount, latestAccount.folderDisplayMode) + } + .distinctUntilChanged() + .flatMapLatest { (account, folderDisplayMode) -> + getDisplayFoldersFlow(account, folderDisplayMode) + } + } + + fun getFolder(account: Account, folderId: Long): Folder? { val messageStore = messageStoreManager.getMessageStore(account) return messageStore.getFolder(folderId) { folder -> Folder( id = folder.id, name = folder.name, - type = folderTypeOf(folder.id), + type = folderTypeOf(account, folder.id), isLocalOnly = folder.isLocalOnly ) } } - fun getFolderDetails(folderId: Long): FolderDetails? { + fun getFolderDetails(account: Account, folderId: Long): FolderDetails? { val messageStore = messageStoreManager.getMessageStore(account) return messageStore.getFolder(folderId) { folder -> FolderDetails( folder = Folder( id = folder.id, name = folder.name, - type = folderTypeOf(folder.id), + type = folderTypeOf(account, folder.id), isLocalOnly = folder.isLocalOnly ), isInTopGroup = folder.isInTopGroup, @@ -84,7 +128,7 @@ class FolderRepository( } } - fun getRemoteFolders(): List { + fun getRemoteFolders(account: Account): List { val messageStore = messageStoreManager.getMessageStore(account) return messageStore.getFolders(excludeLocalOnly = true) { folder -> RemoteFolder( @@ -96,7 +140,7 @@ class FolderRepository( } } - fun getRemoteFolderDetails(): List { + fun getRemoteFolderDetails(account: Account): List { val messageStore = messageStoreManager.getMessageStore(account) return messageStore.getFolders(excludeLocalOnly = true) { folder -> RemoteFolderDetails( @@ -116,22 +160,20 @@ class FolderRepository( } } - fun getPushFoldersFlow(): Flow> { + fun getPushFoldersFlow(account: Account): Flow> { return account.getFolderPushModeFlow() .flatMapLatest { pushMode -> - getPushFoldersFlow(pushMode) + getPushFoldersFlow(account, pushMode) } } - private fun getPushFoldersFlow(folderMode: FolderMode): Flow> { + private fun getPushFoldersFlow(account: Account, folderMode: FolderMode): Flow> { val messageStore = messageStoreManager.getMessageStore(account) return callbackFlow { - send(getPushFolders(folderMode)) + send(getPushFolders(account, folderMode)) val listener = FolderSettingsChangedListener { - launch { - send(getPushFolders(folderMode)) - } + sendBlockingSilently(getPushFolders(account, folderMode)) } messageStore.addFolderSettingsChangedListener(listener) @@ -143,10 +185,10 @@ class FolderRepository( .flowOn(ioDispatcher) } - private fun getPushFolders(folderMode: FolderMode): List { + private fun getPushFolders(account: Account, folderMode: FolderMode): List { if (folderMode == FolderMode.NONE) return emptyList() - return getRemoteFolderDetails() + return getRemoteFolderDetails(account) .asSequence() .filter { folderDetails -> val pushClass = folderDetails.effectivePushClass @@ -164,54 +206,54 @@ class FolderRepository( .toList() } - fun getFolderServerId(folderId: Long): String? { + fun getFolderServerId(account: Account, folderId: Long): String? { val messageStore = messageStoreManager.getMessageStore(account) return messageStore.getFolder(folderId) { folder -> folder.serverId } } - fun getFolderId(folderServerId: String): Long? { + fun getFolderId(account: Account, folderServerId: String): Long? { val messageStore = messageStoreManager.getMessageStore(account) return messageStore.getFolderId(folderServerId) } - fun isFolderPresent(folderId: Long): Boolean { + fun isFolderPresent(account: Account, folderId: Long): Boolean { val messageStore = messageStoreManager.getMessageStore(account) return messageStore.getFolder(folderId) { true } ?: false } - fun updateFolderDetails(folderDetails: FolderDetails) { + fun updateFolderDetails(account: Account, folderDetails: FolderDetails) { val messageStore = messageStoreManager.getMessageStore(account) messageStore.updateFolderSettings(folderDetails) } - fun setIncludeInUnifiedInbox(folderId: Long, includeInUnifiedInbox: Boolean) { + fun setIncludeInUnifiedInbox(account: Account, folderId: Long, includeInUnifiedInbox: Boolean) { val messageStore = messageStoreManager.getMessageStore(account) messageStore.setIncludeInUnifiedInbox(folderId, includeInUnifiedInbox) } - fun setDisplayClass(folderId: Long, folderClass: FolderClass) { + fun setDisplayClass(account: Account, folderId: Long, folderClass: FolderClass) { val messageStore = messageStoreManager.getMessageStore(account) messageStore.setDisplayClass(folderId, folderClass) } - fun setSyncClass(folderId: Long, folderClass: FolderClass) { + fun setSyncClass(account: Account, folderId: Long, folderClass: FolderClass) { val messageStore = messageStoreManager.getMessageStore(account) messageStore.setSyncClass(folderId, folderClass) } - fun setPushClass(folderId: Long, folderClass: FolderClass) { + fun setPushClass(account: Account, folderId: Long, folderClass: FolderClass) { val messageStore = messageStoreManager.getMessageStore(account) messageStore.setPushClass(folderId, folderClass) } - fun setNotificationClass(folderId: Long, folderClass: FolderClass) { + fun setNotificationClass(account: Account, folderId: Long, folderClass: FolderClass) { val messageStore = messageStoreManager.getMessageStore(account) messageStore.setNotificationClass(folderId, folderClass) } - private fun folderTypeOf(folderId: Long) = when (folderId) { + private fun folderTypeOf(account: Account, folderId: Long) = when (folderId) { account.inboxFolderId -> FolderType.INBOX account.outboxFolderId -> FolderType.OUTBOX account.sentFolderId -> FolderType.SENT @@ -244,6 +286,11 @@ class FolderRepository( get() = if (syncClass == FolderClass.INHERITED) displayClass else syncClass } +private data class AccountContainer( + val account: Account, + val folderDisplayMode: FolderMode +) + data class Folder(val id: Long, val name: String, val type: FolderType, val isLocalOnly: Boolean) data class RemoteFolder(val id: Long, val serverId: String, val name: String, val type: FolderType) @@ -271,7 +318,8 @@ data class RemoteFolderDetails( data class DisplayFolder( val folder: Folder, val isInTopGroup: Boolean, - val unreadCount: Int + val unreadMessageCount: Int, + val starredMessageCount: Int ) enum class FolderType { diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/FolderRepositoryManager.kt b/app/core/src/main/java/com/fsck/k9/mailstore/FolderRepositoryManager.kt deleted file mode 100644 index 95bae73c20f8f029c9011ac9c2de9d0262b69b8b..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/mailstore/FolderRepositoryManager.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.fsck.k9.mailstore - -import com.fsck.k9.Account -import com.fsck.k9.preferences.AccountManager - -class FolderRepositoryManager( - private val messageStoreManager: MessageStoreManager, - private val accountManager: AccountManager -) { - fun getFolderRepository(account: Account): FolderRepository { - return FolderRepository(messageStoreManager, accountManager, account) - } -} diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/K9BackendFolder.kt b/app/core/src/main/java/com/fsck/k9/mailstore/K9BackendFolder.kt index e2f71c941d53ba5130608a63190474e0599b0382..c00daf0ea2c543e5a1950caa103a54aece94aafa 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/K9BackendFolder.kt +++ b/app/core/src/main/java/com/fsck/k9/mailstore/K9BackendFolder.kt @@ -35,10 +35,6 @@ class K9BackendFolder( visibleLimit = init.visibleLimit } - override fun getLastUid(): Long? { - return messageStore.getLastUid(folderId) - } - override fun getMessageServerIds(): Set { return messageStore.getMessageServerIds(folderId) } @@ -67,7 +63,7 @@ class K9BackendFolder( } override fun setLastChecked(timestamp: Long) { - messageStore.setLastUpdated(folderId, timestamp) + messageStore.setLastChecked(folderId, timestamp) } override fun setStatus(status: String?) { 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/K9BackendStorageFactory.kt b/app/core/src/main/java/com/fsck/k9/mailstore/K9BackendStorageFactory.kt index ab42cf04a5c71991b20558dc9f42ffaca85193cf..211c74a57cfdbe5fb4316b6671cd93142b54a621 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/K9BackendStorageFactory.kt +++ b/app/core/src/main/java/com/fsck/k9/mailstore/K9BackendStorageFactory.kt @@ -5,13 +5,12 @@ import com.fsck.k9.Preferences class K9BackendStorageFactory( private val preferences: Preferences, - private val folderRepositoryManager: FolderRepositoryManager, + private val folderRepository: FolderRepository, private val messageStoreManager: MessageStoreManager, private val specialFolderSelectionStrategy: SpecialFolderSelectionStrategy, private val saveMessageDataCreator: SaveMessageDataCreator ) { fun createBackendStorage(account: Account): K9BackendStorage { - val folderRepository = folderRepositoryManager.getFolderRepository(account) val messageStore = messageStoreManager.getMessageStore(account) val folderSettingsProvider = FolderSettingsProvider(preferences, account) val specialFolderUpdater = SpecialFolderUpdater( diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/KoinModule.kt b/app/core/src/main/java/com/fsck/k9/mailstore/KoinModule.kt index 3a14fb1bd19a856dcae042875d98bd66dd4d1a81..5f657ecaa410c21e0329eef956d8c7b95087df87 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/KoinModule.kt +++ b/app/core/src/main/java/com/fsck/k9/mailstore/KoinModule.kt @@ -6,7 +6,7 @@ import com.fsck.k9.message.extractors.MessagePreviewCreator import org.koin.dsl.module val mailStoreModule = module { - single { FolderRepositoryManager(messageStoreManager = get(), accountManager = get()) } + single { FolderRepository(messageStoreManager = get(), accountManager = get()) } single { MessageViewInfoExtractorFactory(get(), get(), get()) } single { StorageManager.getInstance(get()) } single { SearchStatusManager() } @@ -14,7 +14,7 @@ val mailStoreModule = module { single { K9BackendStorageFactory( preferences = get(), - folderRepositoryManager = get(), + folderRepository = get(), messageStoreManager = get(), specialFolderSelectionStrategy = get(), saveMessageDataCreator = get() 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 8ef74657a86921895f2105f01d1c80f598c84b04..02b508d7c403b6cb13783f1ae02974ba51ae00b7 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 @@ -33,7 +33,6 @@ import com.fsck.k9.mail.internet.MimeUtility; import com.fsck.k9.mail.internet.SizeAware; 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.AttachmentInfoExtractor; import org.apache.commons.io.IOUtils; @@ -140,38 +139,32 @@ public class LocalFolder { return; } - try { - this.localStore.getDatabase().execute(false, new DbCallback() { - @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException { - Cursor cursor = null; - try { - String baseQuery = "SELECT " + LocalStore.GET_FOLDER_COLS + " FROM folders "; + this.localStore.getDatabase().execute(false, new DbCallback() { + @Override + public Void doDbWork(final SQLiteDatabase db) throws MessagingException { + Cursor cursor = null; + try { + String baseQuery = "SELECT " + LocalStore.GET_FOLDER_COLS + " FROM folders "; - if (serverId != null) { - cursor = db.rawQuery(baseQuery + "where folders.server_id = ?", new String[] { serverId }); - } else { - cursor = db.rawQuery(baseQuery + "where folders.id = ?", new String[] { Long.toString( - databaseId) }); - } + if (serverId != null) { + cursor = db.rawQuery(baseQuery + "where folders.server_id = ?", new String[] { serverId }); + } else { + cursor = db.rawQuery(baseQuery + "where folders.id = ?", new String[] { Long.toString( + databaseId) }); + } - if (cursor.moveToFirst() && !cursor.isNull(LocalStore.FOLDER_ID_INDEX)) { - open(cursor); - } else { - throw new MessagingException("LocalFolder.open(): Folder not found: " + - serverId + " (" + databaseId + ")", true); - } - } catch (MessagingException e) { - throw new WrappedException(e); - } finally { - Utility.closeQuietly(cursor); + if (cursor.moveToFirst() && !cursor.isNull(LocalStore.FOLDER_ID_INDEX)) { + open(cursor); + } else { + throw new MessagingException("LocalFolder.open(): Folder not found: " + + serverId + " (" + databaseId + ")", true); } - return null; + } finally { + Utility.closeQuietly(cursor); } - }); - } catch (WrappedException e) { - throw(MessagingException) e.getCause(); - } + return null; + } + }); } void open(Cursor cursor) throws MessagingException { @@ -214,17 +207,13 @@ public class LocalFolder { } public void setName(String name) throws MessagingException { - try { - open(); - - if (name.equals(this.name)) { - return; - } + open(); - this.name = name; - } catch (MessagingException e) { - throw new WrappedException(e); + if (name.equals(this.name)) { + return; } + + this.name = name; updateFolderColumn("name", name); } @@ -240,7 +229,7 @@ public class LocalFolder { public boolean exists() throws MessagingException { return this.localStore.getDatabase().execute(false, new DbCallback() { @Override - public Boolean doDbWork(final SQLiteDatabase db) throws WrappedException { + public Boolean doDbWork(final SQLiteDatabase db) { Cursor cursor = null; try { cursor = db.rawQuery("SELECT id FROM folders where id = ?", @@ -258,72 +247,30 @@ public class LocalFolder { }); } - public int getMessageCount() throws MessagingException { - try { - return this.localStore.getDatabase().execute(false, new DbCallback() { - @Override - public Integer doDbWork(final SQLiteDatabase db) throws WrappedException { - try { - open(); - } catch (MessagingException e) { - throw new WrappedException(e); - } - Cursor cursor = null; - try { - cursor = db.rawQuery( - "SELECT COUNT(id) FROM messages " + - "WHERE empty = 0 AND deleted = 0 and folder_id = ?", - new String[] { Long.toString(databaseId) }); - cursor.moveToFirst(); - return cursor.getInt(0); //messagecount - } finally { - Utility.closeQuietly(cursor); - } - } - }); - } catch (WrappedException e) { - throw (MessagingException) e.getCause(); - } - } - public int getUnreadMessageCount() throws MessagingException { if (databaseId == -1L) { open(); } - try { - return this.localStore.getDatabase().execute(false, new DbCallback() { - @Override - public Integer doDbWork(final SQLiteDatabase db) throws WrappedException { - int unreadMessageCount = 0; - Cursor cursor = db.query("messages", new String[] { "COUNT(id)" }, - "folder_id = ? AND empty = 0 AND deleted = 0 AND read=0", - new String[] { Long.toString(databaseId) }, null, null, null); + return this.localStore.getDatabase().execute(false, new DbCallback() { + @Override + public Integer doDbWork(final SQLiteDatabase db) { + int unreadMessageCount = 0; + Cursor cursor = db.query("messages", new String[] { "COUNT(id)" }, + "folder_id = ? AND empty = 0 AND deleted = 0 AND read=0", + new String[] { Long.toString(databaseId) }, null, null, null); - try { - if (cursor.moveToFirst()) { - unreadMessageCount = cursor.getInt(0); - } - } finally { - cursor.close(); + try { + if (cursor.moveToFirst()) { + unreadMessageCount = cursor.getInt(0); } - - return unreadMessageCount; + } finally { + cursor.close(); } - }); - } catch (WrappedException e) { - throw(MessagingException) e.getCause(); - } - } - public void setLastChecked(final long lastChecked) throws MessagingException { - try { - open(); - this.lastChecked = lastChecked; - } catch (MessagingException e) { - throw new WrappedException(e); - } - updateFolderColumn("last_updated", lastChecked); + return unreadMessageCount; + } + }); } public int getVisibleLimit() throws MessagingException { @@ -356,22 +303,14 @@ public class LocalFolder { } private void updateFolderColumn(final String column, final Object value) throws MessagingException { - try { - this.localStore.getDatabase().execute(false, new DbCallback() { - @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException { - try { - open(); - } catch (MessagingException e) { - throw new WrappedException(e); - } - db.execSQL("UPDATE folders SET " + column + " = ? WHERE id = ?", new Object[] { value, databaseId }); - return null; - } - }); - } catch (WrappedException e) { - throw(MessagingException) e.getCause(); - } + this.localStore.getDatabase().execute(false, new DbCallback() { + @Override + public Void doDbWork(final SQLiteDatabase db) throws MessagingException { + open(); + db.execSQL("UPDATE folders SET " + column + " = ? WHERE id = ?", new Object[] { value, databaseId }); + return null; + } + }); } public FolderClass getDisplayClass() { @@ -382,26 +321,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()); @@ -450,26 +377,18 @@ public class LocalFolder { public void fetch(final List messages, final FetchProfile fp, final MessageRetrievalListener listener) throws MessagingException { - try { - this.localStore.getDatabase().execute(false, new DbCallback() { - @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException { - try { - open(); - if (fp.contains(FetchProfile.Item.BODY)) { - for (LocalMessage message : messages) { - loadMessageParts(db, message); - } - } - } catch (MessagingException e) { - throw new WrappedException(e); + this.localStore.getDatabase().execute(false, new DbCallback() { + @Override + public Void doDbWork(final SQLiteDatabase db) throws MessagingException { + open(); + if (fp.contains(FetchProfile.Item.BODY)) { + for (LocalMessage message : messages) { + loadMessageParts(db, message); } - return null; } - }); - } catch (WrappedException e) { - throw (MessagingException) e.getCause(); - } + return null; + } + }); } private void loadMessageParts(SQLiteDatabase db, LocalMessage message) throws MessagingException { @@ -578,71 +497,55 @@ public class LocalFolder { } public String getMessageUidById(final long id) throws MessagingException { - try { - return this.localStore.getDatabase().execute(false, new DbCallback() { - @Override - public String doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { - try { - open(); - Cursor cursor = null; - - try { - cursor = db.rawQuery( - "SELECT uid FROM messages WHERE id = ? AND folder_id = ?", - new String[] { Long.toString(id), Long.toString(LocalFolder.this.databaseId) }); - if (!cursor.moveToNext()) { - return null; - } - return cursor.getString(0); - } finally { - Utility.closeQuietly(cursor); - } - } catch (MessagingException e) { - throw new WrappedException(e); + return this.localStore.getDatabase().execute(false, new DbCallback() { + @Override + public String doDbWork(final SQLiteDatabase db) throws MessagingException { + open(); + Cursor cursor = null; + + try { + cursor = db.rawQuery( + "SELECT uid FROM messages WHERE id = ? AND folder_id = ?", + new String[] { Long.toString(id), Long.toString(LocalFolder.this.databaseId) }); + if (!cursor.moveToNext()) { + return null; } + return cursor.getString(0); + } finally { + Utility.closeQuietly(cursor); } - }); - } catch (WrappedException e) { - throw(MessagingException) e.getCause(); - } + } + }); } public LocalMessage getMessage(final String uid) throws MessagingException { - try { - return this.localStore.getDatabase().execute(false, new DbCallback() { - @Override - public LocalMessage doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { - try { - open(); - LocalMessage message = new LocalMessage(LocalFolder.this.localStore, uid, LocalFolder.this); - Cursor cursor = null; - - 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 = ?", - new String[] { message.getUid(), Long.toString(databaseId) }); - - if (!cursor.moveToNext()) { - return null; - } - message.populateFromGetMessageCursor(cursor); - } finally { - Utility.closeQuietly(cursor); - } - return message; - } catch (MessagingException e) { - throw new WrappedException(e); + return this.localStore.getDatabase().execute(false, new DbCallback() { + @Override + public LocalMessage doDbWork(final SQLiteDatabase db) throws MessagingException { + open(); + LocalMessage message = new LocalMessage(LocalFolder.this.localStore, uid, LocalFolder.this); + Cursor cursor = null; + + 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 = ?", + new String[] { message.getUid(), Long.toString(databaseId) }); + + if (!cursor.moveToNext()) { + return null; } + message.populateFromGetMessageCursor(cursor); + } finally { + Utility.closeQuietly(cursor); } - }); - } catch (WrappedException e) { - throw(MessagingException) e.getCause(); - } + return message; + } + }); } @Nullable @@ -679,65 +582,21 @@ public class LocalFolder { public List getMessages(final MessageRetrievalListener listener, final boolean includeDeleted) throws MessagingException { - try { - return localStore.getDatabase().execute(false, new DbCallback>() { - @Override - public List doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { - try { - 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", - new String[] { Long.toString(databaseId) }); - } catch (MessagingException e) { - throw new WrappedException(e); - } - } - }); - } catch (WrappedException e) { - throw(MessagingException) e.getCause(); - } - } - - public List getAllMessageUids() throws MessagingException { - try { - return localStore.getDatabase().execute(false, new DbCallback>() { - @Override - public List doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { - Cursor cursor = null; - ArrayList result = new ArrayList<>(); - - try { - open(); - - cursor = db.rawQuery( - "SELECT uid " + - "FROM messages " + - "WHERE empty = 0 AND deleted = 0 AND " + - "folder_id = ? ORDER BY date DESC", - new String[] { Long.toString(databaseId) }); - - while (cursor.moveToNext()) { - String uid = cursor.getString(0); - result.add(uid); - } - } catch (MessagingException e) { - throw new WrappedException(e); - } finally { - Utility.closeQuietly(cursor); - } - - return result; - } - }); - } catch (WrappedException e) { - throw(MessagingException) e.getCause(); - } + return localStore.getDatabase().execute(false, new DbCallback>() { + @Override + public List doDbWork(final SQLiteDatabase db) throws MessagingException { + 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", + new String[] { Long.toString(databaseId) }); + } + }); } public List getMessagesByUids(@NonNull List uids) throws MessagingException { @@ -776,24 +635,16 @@ public class LocalFolder { return messages; } - public void destroyMessages(final List messages) { - try { - this.localStore.getDatabase().execute(true, new DbCallback() { - @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { - for (LocalMessage message : messages) { - try { - message.destroy(); - } catch (MessagingException e) { - throw new WrappedException(e); - } - } - return null; + public void destroyMessages(final List messages) throws MessagingException { + this.localStore.getDatabase().execute(true, new DbCallback() { + @Override + public Void doDbWork(final SQLiteDatabase db) throws MessagingException { + for (LocalMessage message : messages) { + message.destroy(); } - }); - } catch (MessagingException e) { - throw new WrappedException(e); - } + return null; + } + }); } private void moveTemporaryFile(File tempFile, String messagePartId) throws IOException { @@ -934,20 +785,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; } } @@ -977,7 +823,7 @@ public class LocalFolder { localStore.getDatabase().execute(false, new DbCallback() { @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { + public Void doDbWork(final SQLiteDatabase db) { long messagePartId; Cursor cursor = db.query("message_parts", new String[] { "id" }, "root = ? AND server_extra = ?", @@ -1016,7 +862,7 @@ public class LocalFolder { cv.put("uid", message.getUid()); this.localStore.getDatabase().execute(false, new DbCallback() { @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { + public Void doDbWork(final SQLiteDatabase db) { db.update("messages", cv, "id = ?", new String[] { Long.toString(message.getDatabaseId()) }); return null; @@ -1032,26 +878,21 @@ public class LocalFolder { open(); // Use one transaction to set all flags - try { - this.localStore.getDatabase().execute(true, new DbCallback() { - @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException, - UnavailableStorageException { + this.localStore.getDatabase().execute(true, new DbCallback() { + @Override + public Void doDbWork(final SQLiteDatabase db) { - for (LocalMessage message : messages) { - try { - message.setFlags(flags, value); - } catch (MessagingException e) { - Timber.e(e, "Something went wrong while setting flag"); - } + for (LocalMessage message : messages) { + try { + message.setFlags(flags, value); + } catch (MessagingException e) { + Timber.e(e, "Something went wrong while setting flag"); } - - return null; } - }); - } catch (WrappedException e) { - throw(MessagingException) e.getCause(); - } + + return null; + } + }); } public void setFlags(final Set flags, boolean value) @@ -1067,45 +908,45 @@ public class LocalFolder { open(); - try { - this.localStore.getDatabase().execute(false, new DbCallback() { - @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException { - try { - Cursor cursor = db.query("messages", new String[] { "message_part_id" }, - "folder_id = ? AND empty = 0", - folderIdArg, null, null, null); - try { - while (cursor.moveToNext()) { - long messagePartId = cursor.getLong(0); - deleteMessageDataFromDisk(messagePartId); - } - } finally { - cursor.close(); - } + this.localStore.getDatabase().execute(false, new DbCallback() { + @Override + public Void doDbWork(final SQLiteDatabase db) throws MessagingException { + Cursor cursor = db.query("messages", new String[] { "message_part_id" }, + "folder_id = ? AND empty = 0", + folderIdArg, null, null, null); + try { + while (cursor.moveToNext()) { + long messagePartId = cursor.getLong(0); + deleteMessageDataFromDisk(messagePartId); + } + } finally { + cursor.close(); + } - db.execSQL("DELETE FROM threads WHERE message_id IN " + - "(SELECT id FROM messages WHERE folder_id = ?)", folderIdArg); - db.execSQL("DELETE FROM messages WHERE folder_id = ?", folderIdArg); + db.execSQL("DELETE FROM threads WHERE message_id IN " + + "(SELECT id FROM messages WHERE folder_id = ?)", folderIdArg); + db.execSQL("DELETE FROM messages WHERE folder_id = ?", folderIdArg); - setMoreMessages(MoreMessages.UNKNOWN); + setMoreMessages(MoreMessages.UNKNOWN); + resetLastChecked(db); - return null; - } catch (MessagingException e) { - throw new WrappedException(e); - } - } - }); - } catch (WrappedException e) { - throw(MessagingException) e.getCause(); - } + return null; + } + }); this.localStore.notifyChange(); - setLastChecked(0); setVisibleLimit(getAccount().getDisplayCount()); } + private void resetLastChecked(SQLiteDatabase db) { + lastChecked = 0; + + ContentValues values = new ContentValues(); + values.putNull("last_updated"); + db.update("folders", values, "id = ?", new String[] { Long.toString(databaseId) }); + } + public void destroyLocalOnlyMessages() throws MessagingException { destroyMessages("uid LIKE '" + K9.LOCAL_UID_PREFIX + "%'"); } @@ -1159,66 +1000,57 @@ public class LocalFolder { private void destroyMessage(final long messageId, final long messagePartId, final String messageIdHeader) throws MessagingException { - try { - localStore.getDatabase().execute(true, new DbCallback() { - @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException, - UnavailableStorageException { - try { - deleteMessagePartsAndDataFromDisk(messagePartId); + localStore.getDatabase().execute(true, new DbCallback() { + @Override + public Void doDbWork(final SQLiteDatabase db) throws MessagingException { + deleteMessagePartsAndDataFromDisk(messagePartId); - deleteFulltextIndexEntry(db, messageId); + deleteFulltextIndexEntry(db, messageId); - if (hasThreadChildren(db, messageId)) { - // This message has children in the thread structure so we need to - // make it an empty message. - ContentValues cv = new ContentValues(); - cv.put("id", messageId); - cv.put("folder_id", getDatabaseId()); - cv.put("deleted", 0); - cv.put("message_id", messageIdHeader); - cv.put("empty", 1); + if (hasThreadChildren(db, messageId)) { + // This message has children in the thread structure so we need to + // make it an empty message. + ContentValues cv = new ContentValues(); + cv.put("id", messageId); + cv.put("folder_id", getDatabaseId()); + cv.put("deleted", 0); + cv.put("message_id", messageIdHeader); + cv.put("empty", 1); - db.replace("messages", null, cv); + db.replace("messages", null, cv); - // Nothing else to do - return null; - } + // Nothing else to do + return null; + } - // Get the message ID of the parent message if it's empty - long currentId = getEmptyThreadParent(db, messageId); + // Get the message ID of the parent message if it's empty + long currentId = getEmptyThreadParent(db, messageId); - // Delete the placeholder message - deleteMessageRow(db, messageId); + // Delete the placeholder message + deleteMessageRow(db, messageId); - /* - * Walk the thread tree to delete all empty parents without children - */ + /* + * Walk the thread tree to delete all empty parents without children + */ - while (currentId != -1) { - if (hasThreadChildren(db, currentId)) { - // We made sure there are no empty leaf nodes and can stop now. - break; - } + while (currentId != -1) { + if (hasThreadChildren(db, currentId)) { + // We made sure there are no empty leaf nodes and can stop now. + break; + } - // Get ID of the (empty) parent for the next iteration - long newId = getEmptyThreadParent(db, currentId); + // Get ID of the (empty) parent for the next iteration + long newId = getEmptyThreadParent(db, currentId); - // Delete the empty message - deleteMessageRow(db, currentId); + // Delete the empty message + deleteMessageRow(db, currentId); - currentId = newId; - } - - } catch (MessagingException e) { - throw new WrappedException(e); - } - return null; + currentId = newId; } - }); - } catch (WrappedException e) { - throw (MessagingException) e.getCause(); - } + + return null; + } + }); localStore.notifyChange(); } @@ -1296,7 +1128,7 @@ public class LocalFolder { private void deleteMessageParts(final long rootMessagePartId) throws MessagingException { localStore.getDatabase().execute(false, new DbCallback() { @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { + public Void doDbWork(final SQLiteDatabase db) { db.delete("message_parts", "root = ?", new String[] { Long.toString(rootMessagePartId) }); return null; } @@ -1306,7 +1138,7 @@ public class LocalFolder { private void deleteMessageDataFromDisk(final long rootMessagePartId) throws MessagingException { localStore.getDatabase().execute(false, new DbCallback() { @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { + public Void doDbWork(final SQLiteDatabase db) { deleteMessagePartsFromDisk(db, rootMessagePartId); return null; } @@ -1344,73 +1176,65 @@ public class LocalFolder { public List extractNewMessages(final List messageServerIds) throws MessagingException { - try { - return this.localStore.getDatabase().execute(false, new DbCallback>() { - @Override - public List doDbWork(final SQLiteDatabase db) throws WrappedException { - try { - open(); - } catch (MessagingException e) { - throw new WrappedException(e); - } - - List result = new ArrayList<>(); + return this.localStore.getDatabase().execute(false, new DbCallback>() { + @Override + public List doDbWork(final SQLiteDatabase db) throws MessagingException { + open(); - List selectionArgs = new ArrayList<>(); - Set existingMessages = new HashSet<>(); - int start = 0; + List result = new ArrayList<>(); - while (start < messageServerIds.size()) { - StringBuilder selection = new StringBuilder(); + List selectionArgs = new ArrayList<>(); + Set existingMessages = new HashSet<>(); + int start = 0; - selection.append("folder_id = ? AND UID IN ("); - selectionArgs.add(Long.toString(databaseId)); + while (start < messageServerIds.size()) { + StringBuilder selection = new StringBuilder(); - int count = Math.min(messageServerIds.size() - start, LocalStore.UID_CHECK_BATCH_SIZE); + selection.append("folder_id = ? AND UID IN ("); + selectionArgs.add(Long.toString(databaseId)); - for (int i = start, end = start + count; i < end; i++) { - if (i > start) { - selection.append(",?"); - } else { - selection.append("?"); - } + int count = Math.min(messageServerIds.size() - start, LocalStore.UID_CHECK_BATCH_SIZE); - selectionArgs.add(messageServerIds.get(i)); + for (int i = start, end = start + count; i < end; i++) { + if (i > start) { + selection.append(",?"); + } else { + selection.append("?"); } - selection.append(")"); + selectionArgs.add(messageServerIds.get(i)); + } - Cursor cursor = db.query("messages", LocalStore.UID_CHECK_PROJECTION, - selection.toString(), selectionArgs.toArray(LocalStore.EMPTY_STRING_ARRAY), - null, null, null); + selection.append(")"); - try { - while (cursor.moveToNext()) { - String uid = cursor.getString(0); - existingMessages.add(uid); - } - } finally { - Utility.closeQuietly(cursor); - } + Cursor cursor = db.query("messages", LocalStore.UID_CHECK_PROJECTION, + selection.toString(), selectionArgs.toArray(LocalStore.EMPTY_STRING_ARRAY), + null, null, null); - for (int i = start, end = start + count; i < end; i++) { - String messageServerId = messageServerIds.get(i); - if (!existingMessages.contains(messageServerId)) { - result.add(messageServerId); - } + try { + while (cursor.moveToNext()) { + String uid = cursor.getString(0); + existingMessages.add(uid); } + } finally { + Utility.closeQuietly(cursor); + } - existingMessages.clear(); - selectionArgs.clear(); - start += count; + for (int i = start, end = start + count; i < end; i++) { + String messageServerId = messageServerIds.get(i); + if (!existingMessages.contains(messageServerId)) { + result.add(messageServerId); + } } - return result; + existingMessages.clear(); + selectionArgs.clear(); + start += count; } - }); - } catch (WrappedException e) { - throw(MessagingException) e.getCause(); - } + + return result; + } + }); } private Account getAccount() { 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 51576753675ebed27fe788b64faa1a7d6a0882bd..c3d0de6613b61d26aaf01e263ca96205f5d0dddf 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 @@ -23,7 +23,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; @@ -238,66 +237,50 @@ public class LocalMessage extends MimeMessage { } public void setCachedDecryptedSubject(final String decryptedSubject) throws MessagingException { - try { - this.localStore.getDatabase().execute(true, new DbCallback() { - @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException { - try { - LocalMessage.super.setFlag(Flag.X_SUBJECT_DECRYPTED, true); - } catch (MessagingException e) { - throw new WrappedException(e); - } + this.localStore.getDatabase().execute(true, new DbCallback() { + @Override + public Void doDbWork(final SQLiteDatabase db) throws MessagingException { + LocalMessage.super.setFlag(Flag.X_SUBJECT_DECRYPTED, true); - ContentValues cv = new ContentValues(); - cv.put("subject", decryptedSubject); - cv.put("flags", LocalStore.serializeFlags(getFlags())); + ContentValues cv = new ContentValues(); + cv.put("subject", decryptedSubject); + cv.put("flags", LocalStore.serializeFlags(getFlags())); - db.update("messages", cv, "id = ?", new String[] { Long.toString(databaseId) }); + db.update("messages", cv, "id = ?", new String[] { Long.toString(databaseId) }); - return null; - } - }); - } catch (WrappedException e) { - throw(MessagingException) e.getCause(); - } + return null; + } + }); this.localStore.notifyChange(); } @Override public void setFlag(final Flag flag, final boolean set) throws MessagingException { - - try { - this.localStore.getDatabase().execute(true, new DbCallback() { - @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { - try { - if (flag == Flag.DELETED && set) { - delete(); - } - - LocalMessage.super.setFlag(flag, set); - } catch (MessagingException e) { - throw new WrappedException(e); - } - /* - * Set the flags on the message. - */ - ContentValues cv = new ContentValues(); - cv.put("flags", LocalStore.serializeFlags(getFlags())); - cv.put("read", isSet(Flag.SEEN) ? 1 : 0); - cv.put("flagged", isSet(Flag.FLAGGED) ? 1 : 0); - cv.put("answered", isSet(Flag.ANSWERED) ? 1 : 0); - cv.put("forwarded", isSet(Flag.FORWARDED) ? 1 : 0); - - db.update("messages", cv, "id = ?", new String[] { Long.toString(databaseId) }); - - return null; + this.localStore.getDatabase().execute(true, new DbCallback() { + @Override + public Void doDbWork(final SQLiteDatabase db) throws MessagingException { + if (flag == Flag.DELETED && set) { + delete(); } - }); - } catch (WrappedException e) { - throw(MessagingException) e.getCause(); - } + + LocalMessage.super.setFlag(flag, set); + + /* + * Set the flags on the message. + */ + ContentValues cv = new ContentValues(); + cv.put("flags", LocalStore.serializeFlags(getFlags())); + cv.put("read", isSet(Flag.SEEN) ? 1 : 0); + cv.put("flagged", isSet(Flag.FLAGGED) ? 1 : 0); + cv.put("answered", isSet(Flag.ANSWERED) ? 1 : 0); + cv.put("forwarded", isSet(Flag.FORWARDED) ? 1 : 0); + + db.update("messages", cv, "id = ?", new String[] { Long.toString(databaseId) }); + + return null; + } + }); this.localStore.notifyChange(); } @@ -307,48 +290,40 @@ public class LocalMessage extends MimeMessage { * row since we need to retain the UID for synchronization purposes. */ public void delete() throws MessagingException { - try { - localStore.getDatabase().execute(true, new DbCallback() { - @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException, UnavailableStorageException { - ContentValues cv = new ContentValues(); - cv.put("deleted", 1); - cv.put("preview_type", DatabasePreviewType.fromPreviewType(PreviewType.NONE).getDatabaseValue()); - cv.put("read", 0); - cv.put("flagged", 0); - cv.put("answered", 0); - cv.put("forwarded", 0); - cv.putNull("subject"); - cv.putNull("sender_list"); - cv.putNull("date"); - cv.putNull("to_list"); - cv.putNull("cc_list"); - cv.putNull("bcc_list"); - cv.putNull("preview"); - cv.putNull("reply_to_list"); - cv.putNull("message_part_id"); - cv.putNull("flags"); - cv.putNull("attachment_count"); - cv.putNull("internal_date"); - cv.putNull("mime_type"); - cv.putNull("encryption_type"); - - db.update("messages", cv, "id = ?", new String[] { Long.toString(databaseId) }); - - try { - ((LocalFolder) mFolder).deleteMessagePartsAndDataFromDisk(messagePartId); - } catch (MessagingException e) { - throw new WrappedException(e); - } - - getFolder().deleteFulltextIndexEntry(db, databaseId); - - return null; - } - }); - } catch (WrappedException e) { - throw (MessagingException) e.getCause(); - } + localStore.getDatabase().execute(true, new DbCallback() { + @Override + public Void doDbWork(final SQLiteDatabase db) throws MessagingException { + ContentValues cv = new ContentValues(); + cv.put("deleted", 1); + cv.put("preview_type", DatabasePreviewType.fromPreviewType(PreviewType.NONE).getDatabaseValue()); + cv.put("read", 0); + cv.put("flagged", 0); + cv.put("answered", 0); + cv.put("forwarded", 0); + cv.putNull("subject"); + cv.putNull("sender_list"); + cv.putNull("date"); + cv.putNull("to_list"); + cv.putNull("cc_list"); + cv.putNull("bcc_list"); + cv.putNull("preview"); + cv.putNull("reply_to_list"); + cv.putNull("message_part_id"); + cv.putNull("flags"); + cv.putNull("attachment_count"); + cv.putNull("internal_date"); + cv.putNull("mime_type"); + cv.putNull("encryption_type"); + + db.update("messages", cv, "id = ?", new String[] { Long.toString(databaseId) }); + + mFolder.deleteMessagePartsAndDataFromDisk(messagePartId); + + getFolder().deleteFulltextIndexEntry(db, databaseId); + + return null; + } + }); localStore.notifyChange(); } @@ -358,30 +333,22 @@ public class LocalMessage extends MimeMessage { throw new AssertionError("method must only be used in developer mode!"); } - try { - localStore.getDatabase().execute(true, new DbCallback() { - @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException, MessagingException { - ContentValues cv = new ContentValues(); - cv.putNull("message_part_id"); + localStore.getDatabase().execute(true, new DbCallback() { + @Override + public Void doDbWork(final SQLiteDatabase db) throws MessagingException { + ContentValues cv = new ContentValues(); + cv.putNull("message_part_id"); - db.update("messages", cv, "id = ?", new String[] { Long.toString(databaseId) }); + db.update("messages", cv, "id = ?", new String[] { Long.toString(databaseId) }); - try { - ((LocalFolder) mFolder).deleteMessagePartsAndDataFromDisk(messagePartId); - } catch (MessagingException e) { - throw new WrappedException(e); - } + mFolder.deleteMessagePartsAndDataFromDisk(messagePartId); - setFlag(Flag.X_DOWNLOADED_FULL, false); - setFlag(Flag.X_DOWNLOADED_PARTIAL, false); + setFlag(Flag.X_DOWNLOADED_FULL, false); + setFlag(Flag.X_DOWNLOADED_PARTIAL, false); - return null; - } - }); - } catch (WrappedException e) { - throw (MessagingException) e.getCause(); - } + return null; + } + }); localStore.notifyChange(); } @@ -412,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 01c8dbd6684b24915e95b6e308584c25b5dc5b23..773e2926eb6a8b062d9513927862d4d548e66c26 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 @@ -27,10 +27,11 @@ import android.net.Uri; import androidx.annotation.Nullable; import android.text.TextUtils; +import androidx.core.database.CursorKt; 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.MessagingControllerCommands.PendingCommand; import com.fsck.k9.controller.PendingCommandSerializer; @@ -49,13 +50,8 @@ import com.fsck.k9.mail.Part; import com.fsck.k9.mailstore.LocalFolder.DataLocation; import com.fsck.k9.mailstore.LockableDatabase.DbCallback; import com.fsck.k9.mailstore.LockableDatabase.SchemaDefinition; -import com.fsck.k9.mailstore.LockableDatabase.WrappedException; import com.fsck.k9.mailstore.StorageManager.InternalStorageProvider; -import com.fsck.k9.mailstore.StorageManager.StorageProvider; -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; @@ -115,6 +111,9 @@ public class LocalStore { static final int MSG_INDEX_PREVIEW_TYPE = 24; static final int MSG_INDEX_HEADER_DATA = 25; + static final int MSG_INDEX_NOTIFICATION_ID = 26; + static final int MSG_INDEX_NOTIFICATION_TIMESTAMP = 27; + static final String GET_FOLDER_COLS = "folders.id, name, visible_limit, last_updated, status, " + "integrate, top_group, poll_class, push_class, display_class, notify_class, more_messages, server_id, " + @@ -169,9 +168,6 @@ public class LocalStore { private final Context context; private final ContentResolver contentResolver; - private final MessagePreviewCreator messagePreviewCreator; - private final MessageFulltextCreator messageFulltextCreator; - private final AttachmentCounter attachmentCounter; private final PendingCommandSerializer pendingCommandSerializer; private final AttachmentInfoExtractor attachmentInfoExtractor; @@ -186,15 +182,11 @@ public class LocalStore { /** * local://localhost/path/to/database/uuid.db * This constructor is only used by {@link LocalStoreProvider#getInstance(Account)} - * @throws UnavailableStorageException if not {@link StorageProvider#isReady(Context)} */ private LocalStore(final Account account, final Context context) throws MessagingException { this.context = context; this.contentResolver = context.getContentResolver(); - messagePreviewCreator = MessagePreviewCreator.newInstance(); - messageFulltextCreator = MessageFulltextCreator.newInstance(); - attachmentCounter = AttachmentCounter.newInstance(); pendingCommandSerializer = PendingCommandSerializer.getInstance(); attachmentInfoExtractor = DI.get(AttachmentInfoExtractor.class); @@ -243,122 +235,6 @@ public class LocalStore { return outboxStateRepository; } - public long getSize() throws MessagingException { - - final StorageManager storageManager = StorageManager.getInstance(context); - - final File attachmentDirectory = storageManager.getAttachmentDirectory(account.getUuid(), - database.getStorageProviderId()); - - return database.execute(false, new DbCallback() { - @Override - public Long doDbWork(final SQLiteDatabase db) { - final File[] files = attachmentDirectory.listFiles(); - long attachmentLength = 0; - if (files != null) { - for (File file : files) { - if (file.exists()) { - attachmentLength += file.length(); - } - } - } - - final File dbFile = storageManager.getDatabase(account.getUuid(), database.getStorageProviderId()); - return dbFile.length() + attachmentLength; - } - }); - } - - public void compact() throws MessagingException { - if (K9.isDebugLoggingEnabled()) { - Timber.i("Before compaction size = %d", getSize()); - } - - database.execute(false, new DbCallback() { - @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException { - db.execSQL("VACUUM"); - return null; - } - }); - - if (K9.isDebugLoggingEnabled()) { - Timber.i("After compaction size = %d", getSize()); - } - } - - - public void clear() throws MessagingException { - if (K9.isDebugLoggingEnabled()) { - Timber.i("Before prune size = %d", getSize()); - } - - deleteAllMessageDataFromDisk(); - - if (K9.isDebugLoggingEnabled()) { - Timber.i("After prune / before compaction size = %d", getSize()); - Timber.i("Before clear folder count = %d", getFolderCount()); - Timber.i("Before clear message count = %d", getMessageCount()); - Timber.i("After prune / before clear size = %d", getSize()); - } - - database.execute(false, new DbCallback() { - @Override - public Void doDbWork(final SQLiteDatabase db) { - // We don't care about threads of deleted messages, so delete the whole table. - db.delete("threads", null, null); - - // Don't delete deleted messages. They are essentially placeholders for UIDs of messages that have - // been deleted locally. - db.delete("messages", "deleted = 0", null); - - // We don't need the search data now either - db.delete("messages_fulltext", null, null); - - return null; - } - }); - - compact(); - - if (K9.isDebugLoggingEnabled()) { - Timber.i("After clear message count = %d", getMessageCount()); - Timber.i("After clear size = %d", getSize()); - } - } - - private int getMessageCount() throws MessagingException { - return database.execute(false, new DbCallback() { - @Override - public Integer doDbWork(final SQLiteDatabase db) { - Cursor cursor = null; - try { - cursor = db.rawQuery("SELECT COUNT(*) FROM messages", null); - cursor.moveToFirst(); - return cursor.getInt(0); // message count - } finally { - Utility.closeQuietly(cursor); - } - } - }); - } - - private int getFolderCount() throws MessagingException { - return database.execute(false, new DbCallback() { - @Override - public Integer doDbWork(final SQLiteDatabase db) { - Cursor cursor = null; - try { - cursor = db.rawQuery("SELECT COUNT(*) FROM folders", null); - cursor.moveToFirst(); - return cursor.getInt(0); // folder count - } finally { - Utility.closeQuietly(cursor); - } - } - }); - } - public LocalFolder getFolder(String serverId) { return new LocalFolder(this, serverId); } @@ -374,79 +250,37 @@ public class LocalStore { // TODO this takes about 260-300ms, seems slow. public List getPersonalNamespaces(boolean forceListAll) throws MessagingException { final List folders = new LinkedList<>(); - try { - database.execute(false, new DbCallback>() { - @Override - public List doDbWork(final SQLiteDatabase db) throws WrappedException { - Cursor cursor = null; - try { - cursor = db.rawQuery("SELECT " + GET_FOLDER_COLS + " FROM folders " + - "ORDER BY name ASC", null); - while (cursor.moveToNext()) { - if (cursor.isNull(FOLDER_ID_INDEX)) { - continue; - } - long folderId = cursor.getLong(FOLDER_ID_INDEX); - LocalFolder folder = new LocalFolder(LocalStore.this, folderId); - folder.open(cursor); - - folders.add(folder); + database.execute(false, new DbCallback>() { + @Override + public List doDbWork(final SQLiteDatabase db) throws MessagingException { + Cursor cursor = null; + + try { + cursor = db.rawQuery("SELECT " + GET_FOLDER_COLS + " FROM folders " + + "ORDER BY name ASC", null); + while (cursor.moveToNext()) { + if (cursor.isNull(FOLDER_ID_INDEX)) { + continue; } - return folders; - } catch (MessagingException e) { - throw new WrappedException(e); - } finally { - Utility.closeQuietly(cursor); + long folderId = cursor.getLong(FOLDER_ID_INDEX); + LocalFolder folder = new LocalFolder(LocalStore.this, folderId); + folder.open(cursor); + + folders.add(folder); } + return folders; + } finally { + Utility.closeQuietly(cursor); } - }); - } catch (WrappedException e) { - throw(MessagingException) e.getCause(); - } - return folders; - } - - public void delete() throws UnavailableStorageException { - database.delete(); - } - - public void recreate() throws UnavailableStorageException { - database.recreate(); - } - - private void deleteAllMessageDataFromDisk() throws MessagingException { - markAllMessagePartsDataAsMissing(); - deleteAllMessagePartsDataFromDisk(); - } - - private void markAllMessagePartsDataAsMissing() throws MessagingException { - database.execute(false, new DbCallback() { - @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException { - ContentValues cv = new ContentValues(); - cv.put("data_location", DataLocation.MISSING); - db.update("message_parts", cv, null, null); - - return null; } }); - } - private void deleteAllMessagePartsDataFromDisk() { - final StorageManager storageManager = StorageManager.getInstance(context); - File attachmentDirectory = storageManager.getAttachmentDirectory( - account.getUuid(), database.getStorageProviderId()); - File[] files = attachmentDirectory.listFiles(); - if (files == null) { - return; - } + return folders; + } - for (File file : files) { - if (file.exists() && !file.delete()) { - file.deleteOnExit(); - } - } + public void delete() { + database.delete(); } public void resetVisibleLimits(int visibleLimit) throws MessagingException { @@ -455,7 +289,7 @@ public class LocalStore { cv.put("more_messages", MoreMessages.UNKNOWN.getDatabaseName()); database.execute(false, new DbCallback() { @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException { + public Void doDbWork(final SQLiteDatabase db) { db.update("folders", cv, null, null); return null; } @@ -465,7 +299,7 @@ public class LocalStore { public List getPendingCommands() throws MessagingException { return database.execute(false, new DbCallback>() { @Override - public List doDbWork(final SQLiteDatabase db) throws WrappedException { + public List doDbWork(final SQLiteDatabase db) { Cursor cursor = null; try { cursor = db.query("pending_commands", @@ -498,7 +332,7 @@ public class LocalStore { cv.put("data", pendingCommandSerializer.serialize(command)); database.execute(false, new DbCallback() { @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException { + public Void doDbWork(final SQLiteDatabase db) { db.insert("pending_commands", "command", cv); return null; } @@ -508,7 +342,7 @@ public class LocalStore { public void removePendingCommand(final PendingCommand command) throws MessagingException { database.execute(false, new DbCallback() { @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException { + public Void doDbWork(final SQLiteDatabase db) { db.delete("pending_commands", "id = ?", new String[] { Long.toString(command.databaseId) }); return null; } @@ -518,7 +352,7 @@ public class LocalStore { public void removePendingCommands() throws MessagingException { database.execute(false, new DbCallback() { @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException { + public Void doDbWork(final SQLiteDatabase db) { db.delete("pending_commands", null, null); return null; } @@ -563,7 +397,7 @@ public class LocalStore { final List messages = new ArrayList<>(); final int j = database.execute(false, new DbCallback() { @Override - public Integer doDbWork(final SQLiteDatabase db) throws WrappedException { + public Integer doDbWork(final SQLiteDatabase db) { Cursor cursor = null; int i = 0; try { @@ -620,7 +454,7 @@ public class LocalStore { public AttachmentInfo getAttachmentInfo(final String attachmentId) throws MessagingException { return database.execute(false, new DbCallback() { @Override - public AttachmentInfo doDbWork(final SQLiteDatabase db) throws WrappedException { + public AttachmentInfo doDbWork(final SQLiteDatabase db) { Cursor cursor = db.query("message_parts", new String[] { "display_name", "decoded_body_size", "mime_type" }, "id = ?", @@ -662,7 +496,7 @@ public class LocalStore { try { database.execute(false, new DbCallback() { @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException, MessagingException { + public Void doDbWork(final SQLiteDatabase db) throws MessagingException { Cursor cursor = db.query("message_parts", GET_ATTACHMENT_COLS, "id = ?", new String[] { partId }, @@ -670,7 +504,7 @@ public class LocalStore { try { writeCursorPartsToOutputStream(db, cursor, outputStream); } catch (IOException e) { - throw new WrappedException(e); + throw new MessagingException(e); } finally { Utility.closeQuietly(cursor); } @@ -680,8 +514,6 @@ public class LocalStore { }); } catch (MessagingException e) { throw new IOException("Got a MessagingException while writing attachment data!", e); - } catch (WrappedException e) { - throw (IOException) e.getCause(); } } @@ -859,36 +691,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 long getFolderId(String folderServerId) throws MessagingException { - return database.execute(false, db -> { - try (Cursor cursor = db.query("folders", new String[] { "id" }, - "server_id = ?", new String[] { folderServerId }, - null, null, null) - ) { - if (cursor.moveToFirst()) { - return cursor.getLong(0); - } else { - throw new MessagingException("Folder not found by server ID: " + folderServerId); - } - } - }); - } - public static class AttachmentInfo { public String name; public long size; @@ -936,18 +738,6 @@ public class LocalStore { return database; } - MessagePreviewCreator getMessagePreviewCreator() { - return messagePreviewCreator; - } - - public MessageFulltextCreator getMessageFulltextCreator() { - return messageFulltextCreator; - } - - AttachmentCounter getAttachmentCounter() { - return attachmentCounter; - } - AttachmentInfoExtractor getAttachmentInfoExtractor() { return attachmentInfoExtractor; } @@ -996,24 +786,18 @@ public class LocalStore { selection.append(")"); - try { - database.execute(true, new DbCallback() { - @Override - public Void doDbWork(final SQLiteDatabase db) throws WrappedException, - UnavailableStorageException { - - selectionCallback.doDbWork(db, selection.toString(), - selectionArgs.toArray(new String[selectionArgs.size()])); + database.execute(true, new DbCallback() { + @Override + public Void doDbWork(final SQLiteDatabase db) { - return null; - } - }); + selectionCallback.doDbWork(db, selection.toString(), + selectionArgs.toArray(new String[selectionArgs.size()])); - selectionCallback.postDbWork(); + return null; + } + }); - } catch (WrappedException e) { - throw(MessagingException) e.getCause(); - } + selectionCallback.postDbWork(); selectionArgs.clear(); start += count; @@ -1050,8 +834,7 @@ public class LocalStore { * @param selectionArgs * The current subset of the argument list. */ - void doDbWork(SQLiteDatabase db, String selectionSet, String[] selectionArgs) - throws UnavailableStorageException; + void doDbWork(SQLiteDatabase db, String selectionSet, String[] selectionArgs); /** * This will be executed after each invocation of @@ -1095,8 +878,7 @@ public class LocalStore { } @Override - public void doDbWork(SQLiteDatabase db, String selectionSet, String[] selectionArgs) - throws UnavailableStorageException { + public void doDbWork(SQLiteDatabase db, String selectionSet, String[] selectionArgs) { db.update("messages", cv, "empty = 0 AND id" + selectionSet, selectionArgs); @@ -1142,8 +924,7 @@ public class LocalStore { } @Override - public void doDbWork(SQLiteDatabase db, String selectionSet, String[] selectionArgs) - throws UnavailableStorageException { + public void doDbWork(SQLiteDatabase db, String selectionSet, String[] selectionArgs) { db.execSQL("UPDATE messages SET " + flagColumn + " = " + ((newState) ? "1" : "0") + " WHERE id IN (" + @@ -1193,8 +974,7 @@ public class LocalStore { } @Override - public void doDbWork(SQLiteDatabase db, String selectionSet, String[] selectionArgs) - throws UnavailableStorageException { + public void doDbWork(SQLiteDatabase db, String selectionSet, String[] selectionArgs) { if (threadedList) { String sql = "SELECT m.uid, m.folder_id " + @@ -1260,7 +1040,7 @@ public class LocalStore { return database.execute(false, new DbCallback() { @Override - public Integer doDbWork(SQLiteDatabase db) throws WrappedException, MessagingException { + public Integer doDbWork(SQLiteDatabase db) { Cursor cursor = db.rawQuery(sqlQuery, selectionArgs); try { if (cursor.moveToFirst()) { @@ -1275,6 +1055,69 @@ public class LocalStore { }); } + private int getStarredMessageCount(LocalSearch search) throws MessagingException { + StringBuilder whereBuilder = new StringBuilder(); + List queryArgs = new ArrayList<>(); + SqlQueryBuilder.buildWhereClause(account, search.getConditions(), whereBuilder, queryArgs); + + String where = whereBuilder.toString(); + final String[] selectionArgs = queryArgs.toArray(new String[queryArgs.size()]); + + final String sqlQuery = "SELECT SUM(flagged=1) " + + "FROM messages " + + "JOIN folders ON (folders.id = messages.folder_id) " + + "WHERE (messages.empty = 0 AND messages.deleted = 0)" + + (!TextUtils.isEmpty(where) ? " AND (" + where + ")" : ""); + + return database.execute(false, new DbCallback() { + @Override + public Integer doDbWork(SQLiteDatabase db) { + Cursor cursor = db.rawQuery(sqlQuery, selectionArgs); + try { + if (cursor.moveToFirst()) { + return cursor.getInt(0); + } else { + return 0; + } + } finally { + cursor.close(); + } + } + }); + } + + public MessageCounts getMessageCounts(LocalSearch search) throws MessagingException { + return new MessageCounts(getUnreadMessageCount(search), getStarredMessageCount(search)); + } + + public List getNotificationMessages() throws MessagingException { + return database.execute(false, db -> { + try (Cursor cursor = db.rawQuery( + "SELECT " + GET_MESSAGES_COLS + ", notifications.notification_id, notifications.timestamp " + + "FROM notifications " + + "JOIN messages ON (messages.id = notifications.message_id) " + + "LEFT JOIN threads ON (threads.message_id = messages.id) " + + "LEFT JOIN message_parts ON (message_parts.id = messages.message_part_id) " + + "LEFT JOIN folders ON (folders.id = messages.folder_id) " + + "ORDER BY notifications.timestamp DESC", null) + ) { + List messages = new ArrayList<>(cursor.getCount()); + while (cursor.moveToNext()) { + long folderId = cursor.getLong(MSG_INDEX_FOLDER_ID); + LocalFolder folder = getFolder(folderId); + LocalMessage message = new LocalMessage(LocalStore.this, null, folder); + message.populateFromGetMessageCursor(cursor); + + Integer notificationId = CursorKt.getIntOrNull(cursor, MSG_INDEX_NOTIFICATION_ID); + long notificationTimeStamp = cursor.getLong(MSG_INDEX_NOTIFICATION_TIMESTAMP); + + messages.add(new NotificationMessage(message, notificationId, notificationTimeStamp)); + } + + return messages; + } + }); + } public static String getColumnNameForFlag(Flag flag) { switch (flag) { 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 d846f01dab84cce32d07d950f09c7ecbe2aac4cb..72efef185de4f74fbab6a30074bb16a4e97c6e53 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 @@ -33,11 +33,8 @@ public class LockableDatabase { * The locked database on which the work should occur. Never * null. * @return Any relevant data. Can be null. - * @throws WrappedException - * @throws com.fsck.k9.mail.MessagingException - * @throws com.fsck.k9.mailstore.UnavailableStorageException */ - T doDbWork(SQLiteDatabase db) throws WrappedException, MessagingException; + T doDbWork(SQLiteDatabase db) throws MessagingException; } public interface SchemaDefinition { @@ -49,61 +46,6 @@ public class LockableDatabase { void doDbUpgrade(SQLiteDatabase db); } - /** - * Workaround exception wrapper used to keep the inner exception generated - * in a {@link DbCallback}. - */ - public static class WrappedException extends RuntimeException { - /** - * - */ - private static final long serialVersionUID = 8184421232587399369L; - - public WrappedException(final Exception cause) { - super(cause); - } - } - - /** - * Open the DB on mount and close the DB on unmount - */ - private class StorageListener implements StorageManager.StorageListener { - @Override - public void onUnmount(final String providerId) { - if (!providerId.equals(mStorageProviderId)) { - return; - } - - Timber.d("LockableDatabase: Closing DB %s due to unmount event on StorageProvider: %s", uUid, providerId); - - try { - lockWrite(); - try { - mDb.close(); - } finally { - unlockWrite(); - } - } catch (UnavailableStorageException e) { - Timber.w(e, "Unable to writelock on unmount"); - } - } - - @Override - public void onMount(final String providerId) { - if (!providerId.equals(mStorageProviderId)) { - return; - } - - Timber.d("LockableDatabase: Opening DB %s due to mount event on StorageProvider: %s", uUid, providerId); - - try { - openOrCreateDataspace(); - } catch (UnavailableStorageException e) { - Timber.e(e, "Unable to open DB on mount"); - } - } - } - private String mStorageProviderId; private SQLiteDatabase mDb; @@ -123,8 +65,6 @@ public class LockableDatabase { mWriteLock = lock.writeLock(); } - private final StorageListener mStorageListener = new StorageListener(); - private Context context; /** @@ -173,22 +113,12 @@ public class LockableDatabase { * You have to invoke {@link #unlockRead()} when you're * done with the storage. *

- * - * @throws UnavailableStorageException - * If storage can't be locked because it is not available */ - protected void lockRead() throws UnavailableStorageException { + protected void lockRead() { mReadLock.lock(); - try { - getStorageManager().lockProvider(mStorageProviderId); - } catch (UnavailableStorageException | RuntimeException e) { - mReadLock.unlock(); - throw e; - } } protected void unlockRead() { - getStorageManager().unlockProvider(mStorageProviderId); mReadLock.unlock(); } @@ -200,45 +130,12 @@ public class LockableDatabase { * You have to invoke {@link #unlockWrite()} when you're * done with the storage. *

- * - * @throws UnavailableStorageException - * If storage can't be locked because it is not available. */ - protected void lockWrite() throws UnavailableStorageException { - lockWrite(mStorageProviderId); - } - - /** - * Lock the storage for exclusive access (other threads aren't allowed to - * run simultaneously) - * - *

- * You have to invoke {@link #unlockWrite()} when you're - * done with the storage. - *

- * - * @param providerId - * Never null. - * - * @throws UnavailableStorageException - * If storage can't be locked because it is not available. - */ - protected void lockWrite(final String providerId) throws UnavailableStorageException { + private void lockWrite() { mWriteLock.lock(); - try { - getStorageManager().lockProvider(providerId); - } catch (UnavailableStorageException | RuntimeException e) { - mWriteLock.unlock(); - throw e; - } } - protected void unlockWrite() { - unlockWrite(mStorageProviderId); - } - - protected void unlockWrite(final String providerId) { - getStorageManager().unlockProvider(providerId); + private void unlockWrite() { mWriteLock.unlock(); } @@ -258,7 +155,6 @@ public class LockableDatabase { * @param callback * Never null. * @return Whatever {@link DbCallback#doDbWork(SQLiteDatabase)} returns. - * @throws UnavailableStorageException */ public T execute(final boolean transactional, final DbCallback callback) throws MessagingException { lockRead(); @@ -314,58 +210,47 @@ public class LockableDatabase { Timber.v("LockableDatabase: Switching provider from %s to %s", mStorageProviderId, newProviderId); final String oldProviderId = mStorageProviderId; - lockWrite(oldProviderId); + lockWrite(); try { - lockWrite(newProviderId); try { - try { - mDb.close(); - } catch (Exception e) { - Timber.i(e, "Unable to close DB on local store migration"); - } + mDb.close(); + } catch (Exception e) { + Timber.i(e, "Unable to close DB on local store migration"); + } - final StorageManager storageManager = getStorageManager(); - File oldDatabase = storageManager.getDatabase(uUid, oldProviderId); + final StorageManager storageManager = getStorageManager(); + File oldDatabase = storageManager.getDatabase(uUid, oldProviderId); - // create new path - prepareStorage(newProviderId); + // create new path + prepareStorage(newProviderId); - // move all database files - FileHelper.moveRecursive(oldDatabase, storageManager.getDatabase(uUid, newProviderId)); - // move all attachment files - FileHelper.moveRecursive(storageManager.getAttachmentDirectory(uUid, oldProviderId), - storageManager.getAttachmentDirectory(uUid, newProviderId)); - // remove any remaining old journal files - deleteDatabase(oldDatabase); + // move all database files + FileHelper.moveRecursive(oldDatabase, storageManager.getDatabase(uUid, newProviderId)); + // move all attachment files + FileHelper.moveRecursive(storageManager.getAttachmentDirectory(uUid, oldProviderId), + storageManager.getAttachmentDirectory(uUid, newProviderId)); + // remove any remaining old journal files + deleteDatabase(oldDatabase); - mStorageProviderId = newProviderId; + mStorageProviderId = newProviderId; - // re-initialize this class with the new Uri - openOrCreateDataspace(); - } finally { - unlockWrite(newProviderId); - } + // re-initialize this class with the new Uri + openOrCreateDataspace(); } finally { - unlockWrite(oldProviderId); + unlockWrite(); } } - public void open() throws UnavailableStorageException { + public void open() { lockWrite(); try { openOrCreateDataspace(); } finally { unlockWrite(); } - StorageManager.getInstance(context).addListener(mStorageListener); } - /** - * - * @throws UnavailableStorageException - */ - private void openOrCreateDataspace() throws UnavailableStorageException { - + private void openOrCreateDataspace() { lockWrite(); try { final File databaseFile = prepareStorage(mStorageProviderId); @@ -401,13 +286,7 @@ public class LockableDatabase { } } - /** - * @param providerId - * Never null. - * @return DB file. - * @throws UnavailableStorageException - */ - protected File prepareStorage(final String providerId) throws UnavailableStorageException { + protected File prepareStorage(final String providerId) { final StorageManager storageManager = getStorageManager(); final File databaseFile = storageManager.getDatabase(uUid, providerId); @@ -419,8 +298,7 @@ public class LockableDatabase { } if (!databaseParentDir.exists()) { if (!databaseParentDir.mkdirs()) { - // Android seems to be unmounting the storage... - throw new UnavailableStorageException("Unable to access: " + databaseParentDir); + throw new RuntimeException("Unable to access: " + databaseParentDir); } FileHelper.touchFile(databaseParentDir, ".nomedia"); } @@ -441,23 +319,20 @@ public class LockableDatabase { /** * Delete the backing database. - * - * @throws UnavailableStorageException */ - public void delete() throws UnavailableStorageException { + public void delete() { delete(false); } - public void recreate() throws UnavailableStorageException { + public void recreate() { delete(true); } /** * @param recreate * true if the DB should be recreated after delete - * @throws UnavailableStorageException */ - private void delete(final boolean recreate) throws UnavailableStorageException { + private void delete(final boolean recreate) { lockWrite(); try { try { @@ -494,9 +369,6 @@ public class LockableDatabase { if (recreate) { openOrCreateDataspace(); - } else { - // stop waiting for mount/unmount events - getStorageManager().removeListener(mStorageListener); } } finally { unlockWrite(); 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 e59aeaebb0ae0a1d6dde87de39315ba288dff086..9294ac736f5ec052342cde7cf7d8c772f3f87deb 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 @@ -78,6 +78,16 @@ interface MessageStore { */ fun setMessageFlag(folderId: Long, messageServerId: String, flag: Flag, set: Boolean) + /** + * Set whether a message should be considered as new. + */ + fun setNewMessageState(folderId: Long, messageServerId: String, newMessage: Boolean) + + /** + * Clear the new message state for all messages. + */ + fun clearNewMessageState() + /** * Retrieve the server ID for a given message. */ @@ -121,9 +131,9 @@ interface MessageStore { fun getHeaders(folderId: Long, messageServerId: String): List
/** - * Get highest UID (message server ID) + * Return the size of this message store in bytes. */ - fun getLastUid(folderId: Long): Long? + fun getSize(): Long /** * Remove messages from the store. @@ -166,11 +176,26 @@ interface MessageStore { */ fun getDisplayFolders(displayMode: FolderMode, outboxFolderId: Long?, mapper: FolderMapper): List + /** + * Check if all given folders are included in the Unified Inbox. + */ + fun areAllIncludedInUnifiedInbox(folderIds: Collection): Boolean + /** * Find a folder with the given server ID and return its store ID. */ 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. + */ + fun getMessageCount(folderId: Long): Int + /** * Update a folder's name and type. */ @@ -212,9 +237,9 @@ interface MessageStore { fun setMoreMessages(folderId: Long, moreMessages: MoreMessages) /** - * Update the 'last updated' state of a folder. + * Update the time when the folder was last checked for new messages. */ - fun setLastUpdated(folderId: Long, timestamp: Long) + fun setLastChecked(folderId: Long, timestamp: Long) /** * Update folder status message. @@ -267,4 +292,9 @@ interface MessageStore { * Create or update a number property associated with the given folder. */ fun setFolderExtraNumber(folderId: Long, name: String, value: Long) + + /** + * Optimize the message store with the goal of using the minimal amount of disk space. + */ + fun compact() } 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/MessageViewInfoExtractor.java b/app/core/src/main/java/com/fsck/k9/mailstore/MessageViewInfoExtractor.java index 4499e955b90033b7996650b3b00e7fdb3fc9b3ec..a3a5cead774b2fca8bfaa04f34ec7c11433b35e9 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/MessageViewInfoExtractor.java +++ b/app/core/src/main/java/com/fsck/k9/mailstore/MessageViewInfoExtractor.java @@ -20,9 +20,7 @@ import com.fsck.k9.mail.Part; import com.fsck.k9.mail.internet.MessageExtractor; import com.fsck.k9.mail.internet.MimeUtility; import com.fsck.k9.mail.internet.Viewable; -import com.fsck.k9.mail.internet.Viewable.Flowed; import com.fsck.k9.mailstore.CryptoResultAnnotation.CryptoError; -import com.fsck.k9.mailstore.util.FlowedMessageUtils; import com.fsck.k9.message.extractors.AttachmentInfoExtractor; import com.fsck.k9.message.html.HtmlConverter; import com.fsck.k9.message.html.HtmlProcessor; @@ -271,10 +269,6 @@ public class MessageViewInfoExtractor { String t = getTextFromPart(part); if (t == null) { t = ""; - } else if (viewable instanceof Flowed) { - boolean delSp = ((Flowed) viewable).isDelSp(); - t = FlowedMessageUtils.deflow(t, delSp); - t = HtmlConverter.textToHtml(t); } else if (viewable instanceof Text) { t = HtmlConverter.textToHtml(t); } else if (!(viewable instanceof Html)) { @@ -310,9 +304,6 @@ public class MessageViewInfoExtractor { t = ""; } else if (viewable instanceof Html) { t = HtmlConverter.htmlToText(t); - } else if (viewable instanceof Flowed) { - boolean delSp = ((Flowed) viewable).isDelSp(); - t = FlowedMessageUtils.deflow(t, delSp); } else if (!(viewable instanceof Text)) { throw new IllegalStateException("unhandled case!"); } diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/NotificationMessage.kt b/app/core/src/main/java/com/fsck/k9/mailstore/NotificationMessage.kt new file mode 100644 index 0000000000000000000000000000000000000000..dddb1073c8e61da65a54f5fb31d06689498e37c1 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/mailstore/NotificationMessage.kt @@ -0,0 +1,7 @@ +package com.fsck.k9.mailstore + +data class NotificationMessage( + val message: LocalMessage, + val notificationId: Int?, + val timestamp: Long +) diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/NotifierMessageStore.kt b/app/core/src/main/java/com/fsck/k9/mailstore/NotifierMessageStore.kt index f3bd1276ab9c8096cc208d329e04b05ce3b5ec37..488dbbffd3662c65d5756832a5a64f9fd160cc09 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/NotifierMessageStore.kt +++ b/app/core/src/main/java/com/fsck/k9/mailstore/NotifierMessageStore.kt @@ -43,6 +43,16 @@ class NotifierMessageStore( notifyChange() } + override fun setNewMessageState(folderId: Long, messageServerId: String, newMessage: Boolean) { + messageStore.setNewMessageState(folderId, messageServerId, newMessage) + notifyChange() + } + + override fun clearNewMessageState() { + messageStore.clearNewMessageState() + notifyChange() + } + override fun destroyMessages(folderId: Long, messageServerIds: Collection) { messageStore.destroyMessages(folderId, messageServerIds) notifyChange() diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/OutboxStateRepository.kt b/app/core/src/main/java/com/fsck/k9/mailstore/OutboxStateRepository.kt index 794e93af637bb141cfedd50a5c34cf1648f0829f..c2c11aa932f6977b9b137d310384c7c2cbc6609a 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/OutboxStateRepository.kt +++ b/app/core/src/main/java/com/fsck/k9/mailstore/OutboxStateRepository.kt @@ -2,6 +2,10 @@ package com.fsck.k9.mailstore import android.content.ContentValues import com.fsck.k9.Clock +import com.fsck.k9.helper.getIntOrThrow +import com.fsck.k9.helper.getLongOrThrow +import com.fsck.k9.helper.getStringOrNull +import com.fsck.k9.helper.getStringOrThrow class OutboxStateRepository(private val database: LockableDatabase, private val clock: Clock) { @@ -17,11 +21,10 @@ class OutboxStateRepository(private val database: LockableDatabase, private val throw IllegalStateException("No outbox_state entry for message with id $messageId") } - val sendStateString = cursor.getString(cursor.getColumnIndex(COLUMN_SEND_STATE)) - val numberOfSendAttempts = cursor.getInt(cursor.getColumnIndex(COLUMN_NUMBER_OF_SEND_ATTEMPTS)) - val sendErrorTimestamp = cursor.getLong(cursor.getColumnIndex(COLUMN_ERROR_TIMESTAMP)) - val sendErrorColumnIndex = cursor.getColumnIndex(COLUMN_ERROR) - val sendError = if (cursor.isNull(sendErrorColumnIndex)) null else cursor.getString(sendErrorColumnIndex) + val sendStateString = cursor.getStringOrThrow(COLUMN_SEND_STATE) + val numberOfSendAttempts = cursor.getIntOrThrow(COLUMN_NUMBER_OF_SEND_ATTEMPTS) + val sendErrorTimestamp = cursor.getLongOrThrow(COLUMN_ERROR_TIMESTAMP) + val sendError = cursor.getStringOrNull(COLUMN_ERROR) val sendState = SendState.fromDatabaseName(sendStateString) diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/SpecialFolderUpdater.kt b/app/core/src/main/java/com/fsck/k9/mailstore/SpecialFolderUpdater.kt index 908699a8bbb67213e27bb538914719974c1a926e..e25f08c7767681e830fe2b62a4055102a78bb444 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/SpecialFolderUpdater.kt +++ b/app/core/src/main/java/com/fsck/k9/mailstore/SpecialFolderUpdater.kt @@ -18,7 +18,7 @@ class SpecialFolderUpdater( private val account: Account ) { fun updateSpecialFolders() { - val folders = folderRepository.getRemoteFolders() + val folders = folderRepository.getRemoteFolders(account) updateInbox(folders) @@ -42,15 +42,15 @@ class SpecialFolderUpdater( account.inboxFolderId = newInboxId if (oldInboxId != null && folders.any { it.id == oldInboxId }) { - folderRepository.setIncludeInUnifiedInbox(oldInboxId, false) + folderRepository.setIncludeInUnifiedInbox(account, oldInboxId, false) } if (newInboxId != null) { - folderRepository.setIncludeInUnifiedInbox(newInboxId, true) - folderRepository.setDisplayClass(newInboxId, FolderClass.FIRST_CLASS) - folderRepository.setSyncClass(newInboxId, FolderClass.FIRST_CLASS) - folderRepository.setPushClass(newInboxId, FolderClass.FIRST_CLASS) - folderRepository.setNotificationClass(newInboxId, FolderClass.FIRST_CLASS) + folderRepository.setIncludeInUnifiedInbox(account, newInboxId, true) + folderRepository.setDisplayClass(account, newInboxId, FolderClass.FIRST_CLASS) + folderRepository.setSyncClass(account, newInboxId, FolderClass.FIRST_CLASS) + folderRepository.setPushClass(account, newInboxId, FolderClass.FIRST_CLASS) + folderRepository.setNotificationClass(account, newInboxId, FolderClass.FIRST_CLASS) } } @@ -117,8 +117,8 @@ class SpecialFolderUpdater( } if (folderId != null) { - folderRepository.setDisplayClass(folderId, FolderClass.FIRST_CLASS) - folderRepository.setSyncClass(folderId, FolderClass.NO_CLASS) + folderRepository.setDisplayClass(account, folderId, FolderClass.FIRST_CLASS) + folderRepository.setSyncClass(account, folderId, FolderClass.NO_CLASS) } } 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 5e1ca29a19337ef6b4dc4eb7f9bc37455efec4bd..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 @@ -2,24 +2,15 @@ package com.fsck.k9.mailstore; import java.io.File; -import java.io.IOException; -import java.util.ArrayList; import java.util.Arrays; -import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReadWriteLock; -import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.Set; import android.content.Context; import android.os.Environment; -import com.fsck.k9.Core; -import com.fsck.k9.CoreResourceProvider; -import com.fsck.k9.DI; -import timber.log.Timber; /** * Manager for different {@link StorageProvider} -classes that abstract access @@ -64,14 +55,6 @@ public class StorageManager { */ 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 @@ -110,135 +93,6 @@ public class StorageManager { * @return Never null. */ File getAttachmentDirectory(Context context, String id); - - /** - * Check for the underlying storage availability. - * - * @param context - * Never null. - * @return Whether the underlying storage returned by this provider is - * ready for read/write operations at the time of invocation. - */ - boolean isReady(Context context); - - /** - * Retrieve the root of the underlying storage. - * - * @param context - * Never null. - * @return The root directory of the denoted storage. Never - * null. - */ - File getRoot(Context context); - } - - /** - * Interface for components wanting to be notified of storage availability - * events. - */ - public interface StorageListener { - /** - * Invoked on storage mount (with read/write access). - * - * @param providerId - * Identifier (as returned by {@link StorageProvider#getId()} - * of the newly mounted storage. Never null. - */ - void onMount(String providerId); - - /** - * Invoked when a storage is about to be unmounted. - * - * @param providerId - * Identifier (as returned by {@link StorageProvider#getId()} - * of the to-be-unmounted storage. Never null. - */ - void onUnmount(String providerId); - } - - /** - * Base provider class for providers that rely on well-known path to check - * for storage availability. - * - *

- * Since solely checking for paths can be unsafe, this class allows to check - * for device compatibility using {@link #supportsVendor()}. If the vendor - * specific check fails, the provider won't be able to provide any valid - * File handle, regardless of the path existence. - *

- * - *

- * Moreover, this class validates the denoted storage path against mount - * points using {@link StorageManager#isMountPoint(File)}. - *

- */ - public abstract static class FixedStorageProviderBase implements StorageProvider { - /** - * The root of the denoted storage. Used for mount points checking. - */ - private File mRoot; - - /** - * Chosen base directory - */ - private File mApplicationDir; - - @Override - public void init(final Context context) { - mRoot = computeRoot(context); - // use /k9 - mApplicationDir = new File(mRoot, "k9"); - } - - /** - * Vendor specific checks - * - * @return Whether this provider supports the underlying vendor specific - * storage - */ - protected abstract boolean supportsVendor(); - - @Override - public boolean isReady(Context context) { - try { - final File root = mRoot.getCanonicalFile(); - return isMountPoint(root) - && Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()); - } catch (IOException e) { - Timber.w(e, "Specified root isn't ready: %s", mRoot); - return false; - } - } - - @Override - public final boolean isSupported(Context context) { - return mRoot.isDirectory() && supportsVendor(); - } - - @Override - public File getDatabase(Context context, String id) { - return new File(mApplicationDir, id + ".db"); - } - - @Override - public File getAttachmentDirectory(Context context, String id) { - return new File(mApplicationDir, id + ".db_att"); - } - - @Override - public final File getRoot(Context context) { - return mRoot; - } - - /** - * Retrieve the well-known storage root directory from the actual - * implementation. - * - * @param context - * Never null. - * @return Never null. - */ - protected abstract File computeRoot(Context context); } /** @@ -257,13 +111,6 @@ public class StorageManager { public static class InternalStorageProvider implements StorageProvider { public static final String ID = "InternalStorage"; - private final CoreResourceProvider resourceProvider; - private File mRoot; - - public InternalStorageProvider(CoreResourceProvider resourceProvider) { - this.resourceProvider = resourceProvider; - } - @Override public String getId() { return ID; @@ -271,13 +118,6 @@ public class StorageManager { @Override public void init(Context context) { - // XXX - mRoot = new File("/"); - } - - @Override - public String getName(Context context) { - return resourceProvider.internalStorageProviderName(); } @Override @@ -295,16 +135,6 @@ public class StorageManager { // we store attachments in the database directory return context.getDatabasePath(id + ".db_att"); } - - @Override - public boolean isReady(Context context) { - return true; - } - - @Override - public File getRoot(Context context) { - return mRoot; - } } /** @@ -328,18 +158,12 @@ public class StorageManager { 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; @@ -350,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; @@ -369,37 +188,6 @@ public class StorageManager { public File getAttachmentDirectory(Context context, String id) { return new File(mApplicationDirectory, id + ".db_att"); } - - @Override - public boolean isReady(Context context) { - return Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()); - } - - @Override - public File getRoot(Context context) { - return Environment.getExternalStorageDirectory(); - } - } - - /** - * Stores storage provider locking information - */ - public static class SynchronizationAid { - /** - * {@link Lock} has a thread semantic so it can't be released from - * another thread - this flags act as a holder for the unmount state - */ - public boolean unmounting = false; - - public final Lock readLock; - - public final Lock writeLock; - - { - final ReadWriteLock readWriteLock = new ReentrantReadWriteLock(true); - readLock = readWriteLock.readLock(); - writeLock = readWriteLock.writeLock(); - } } /** @@ -407,51 +195,25 @@ public class StorageManager { */ private final Map mProviders = new LinkedHashMap<>(); - /** - * Locking data for the active storage providers. - */ - private final Map mProviderLocks = new IdentityHashMap<>(); - protected final Context context; - /** - * Listener to be notified for storage related events. - */ - private List mListeners = new ArrayList<>(); - private static transient StorageManager instance; 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 file - * Canonical file to match. Never null. - * @return Whether the specified file matches a filesystem root. - * @throws IOException - */ - public static boolean isMountPoint(final File file) { - for (final File root : File.listRoots()) { - if (root.equals(file)) { - return true; - } - } - return false; - } - /** * @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"); } @@ -468,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)) { @@ -477,7 +240,6 @@ public class StorageManager { provider.init(context); mProviders.put(provider.getId(), provider); - mProviderLocks.put(provider, new SynchronizationAid()); } } @@ -526,152 +288,7 @@ public class StorageManager { return provider.getAttachmentDirectory(context, dbName); } - /** - * @param providerId - * Never null. - * @return Whether the specified provider is ready for read/write operations - */ - public boolean isReady(final String providerId) { - StorageProvider provider = getProvider(providerId); - if (provider == null) { - Timber.w("Storage-Provider \"%s\" does not exist", providerId); - return false; - } - return provider.isReady(context); - } - - /** - * @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; - } - - /** - * @param path - */ - public void onBeforeUnmount(final String path) { - Timber.i("storage path \"%s\" unmounting", path); - final StorageProvider provider = resolveProvider(path); - if (provider == null) { - return; - } - for (final StorageListener listener : mListeners) { - try { - listener.onUnmount(provider.getId()); - } catch (Exception e) { - Timber.w(e, "Error while notifying StorageListener"); - } - } - final SynchronizationAid sync = mProviderLocks.get(resolveProvider(path)); - sync.writeLock.lock(); - sync.unmounting = true; - sync.writeLock.unlock(); - } - - public void onAfterUnmount(final String path) { - Timber.i("storage path \"%s\" unmounted", path); - final StorageProvider provider = resolveProvider(path); - if (provider == null) { - return; - } - final SynchronizationAid sync = mProviderLocks.get(resolveProvider(path)); - sync.writeLock.lock(); - sync.unmounting = false; - sync.writeLock.unlock(); - - Core.setServicesEnabled(context); - } - - /** - * @param path - * @param readOnly - */ - public void onMount(final String path, final boolean readOnly) { - Timber.i("storage path \"%s\" mounted readOnly=%s", path, readOnly); - if (readOnly) { - return; - } - - final StorageProvider provider = resolveProvider(path); - if (provider == null) { - return; - } - for (final StorageListener listener : mListeners) { - try { - listener.onMount(provider.getId()); - } catch (Exception e) { - Timber.w(e, "Error while notifying StorageListener"); - } - } - - // XXX we should reset mail service ONLY if there are accounts using the storage (this is not done in a regular listener because it has to be invoked afterward) - Core.setServicesEnabled(context); - } - - /** - * @param path - * Never null. - * @return The corresponding provider. null if no match. - */ - protected StorageProvider resolveProvider(final String path) { - for (final StorageProvider provider : mProviders.values()) { - if (path.equals(provider.getRoot(context).getAbsolutePath())) { - return provider; - } - } - return null; - } - - public void addListener(final StorageListener listener) { - mListeners.add(listener); - } - - public void removeListener(final StorageListener listener) { - mListeners.remove(listener); - } - - /** - * Try to lock the underlying storage to prevent concurrent unmount. - * - *

- * You must invoke {@link #unlockProvider(String)} when you're done with the - * storage. - *

- * - * @param providerId - * @throws UnavailableStorageException - * If the storage can't be locked. - */ - public void lockProvider(final String providerId) throws UnavailableStorageException { - final StorageProvider provider = getProvider(providerId); - if (provider == null) { - throw new UnavailableStorageException("StorageProvider not found: " + providerId); - } - // lock provider - final SynchronizationAid sync = mProviderLocks.get(provider); - final boolean locked = sync.readLock.tryLock(); - if (!locked || (locked && sync.unmounting)) { - if (locked) { - sync.readLock.unlock(); - } - throw new UnavailableStorageException("StorageProvider is unmounting"); - } else if (locked && !provider.isReady(context)) { - sync.readLock.unlock(); - throw new UnavailableStorageException("StorageProvider not ready"); - } - } - - public void unlockProvider(final String providerId) { - final StorageProvider provider = getProvider(providerId); - final SynchronizationAid sync = mProviderLocks.get(provider); - sync.readLock.unlock(); + public Set getAvailableProviders() { + return mProviders.keySet(); } } diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/UnavailableStorageException.java b/app/core/src/main/java/com/fsck/k9/mailstore/UnavailableStorageException.java deleted file mode 100644 index 914add6e69d7ada7310340a2b4554372eb7d099c..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/mailstore/UnavailableStorageException.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.fsck.k9.mailstore; - -import com.fsck.k9.mail.MessagingException; - -public class UnavailableStorageException extends MessagingException { - - private static final long serialVersionUID = 1348267375054620792L; - - public UnavailableStorageException(String message) { - // consider this exception as a permanent failure by default - this(message, true); - } - - public UnavailableStorageException(String message, boolean perm) { - super(message, perm); - } -} diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/util/FlowedMessageUtils.java b/app/core/src/main/java/com/fsck/k9/mailstore/util/FlowedMessageUtils.java deleted file mode 100644 index d30665a9251ce731d4cd3d735053a1a6aef8ffc7..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/mailstore/util/FlowedMessageUtils.java +++ /dev/null @@ -1,102 +0,0 @@ -package com.fsck.k9.mailstore.util; - - -/** - * Adapted from the Apache James project, see - * https://james.apache.org/mailet/base/apidocs/org/apache/mailet/base/FlowedMessageUtils.html - * - *

Manages texts encoded as text/plain; format=flowed.

- *

As a reference see:

- * - *

Note

- *
    - *
  • In order to decode, the input text must belong to a mail with headers similar to: - * Content-Type: text/plain; charset="CHARSET"; [delsp="yes|no"; ]format="flowed" - * (the quotes around CHARSET are not mandatory). - * Furthermore the header Content-Transfer-Encoding MUST NOT BE Quoted-Printable - * (see RFC3676 paragraph 4.2).(In fact this happens often for non 7bit messages). - *
  • - *
- */ -public final class FlowedMessageUtils { - private static final char RFC2646_SPACE = ' '; - private static final char RFC2646_QUOTE = '>'; - private static final String RFC2646_SIGNATURE = "-- "; - private static final String RFC2646_CRLF = "\r\n"; - - private FlowedMessageUtils() { - // this class cannot be instantiated - } - - /** - * Decodes a text previously wrapped using "format=flowed". - */ - public static String deflow(String text, boolean delSp) { - String[] lines = text.split("\r\n|\n", -1); - StringBuffer result = null; - StringBuffer resultLine = new StringBuffer(); - int resultLineQuoteDepth = 0; - boolean resultLineFlowed = false; - // One more cycle, to close the last line - for (int i = 0; i <= lines.length; i++) { - String line = i < lines.length ? lines[i] : null; - int actualQuoteDepth = 0; - - if (line != null && line.length() > 0) { - if (line.equals(RFC2646_SIGNATURE)) - // signature handling (the previous line is not flowed) - resultLineFlowed = false; - - else if (line.charAt(0) == RFC2646_QUOTE) { - // Quote - actualQuoteDepth = 1; - while (actualQuoteDepth < line.length() && line.charAt(actualQuoteDepth) == RFC2646_QUOTE) actualQuoteDepth ++; - // if quote-depth changes wrt the previous line then this is not flowed - if (resultLineQuoteDepth != actualQuoteDepth) resultLineFlowed = false; - line = line.substring(actualQuoteDepth); - - } else { - // id quote-depth changes wrt the first line then this is not flowed - if (resultLineQuoteDepth > 0) resultLineFlowed = false; - } - - if (line.length() > 0 && line.charAt(0) == RFC2646_SPACE) - // Line space-stuffed - line = line.substring(1); - - // if the previous was the last then it was not flowed - } else if (line == null) resultLineFlowed = false; - - // Add the PREVIOUS line. - // This often will find the flow looking for a space as the last char of the line. - // With quote changes or signatures it could be the followinf line to void the flow. - if (!resultLineFlowed && i > 0) { - if (resultLineQuoteDepth > 0) resultLine.insert(0, RFC2646_SPACE); - for (int j = 0; j < resultLineQuoteDepth; j++) resultLine.insert(0, RFC2646_QUOTE); - if (result == null) result = new StringBuffer(); - else result.append(RFC2646_CRLF); - result.append(resultLine.toString()); - resultLine = new StringBuffer(); - resultLineFlowed = false; - } - resultLineQuoteDepth = actualQuoteDepth; - - if (line != null) { - if (!line.equals(RFC2646_SIGNATURE) && line.endsWith("" + RFC2646_SPACE) && i < lines.length - 1) { - // Line flowed (NOTE: for the split operation the line having i == lines.length is the last that does not end with RFC2646_CRLF) - if (delSp) line = line.substring(0, line.length() - 1); - resultLineFlowed = true; - } - - else resultLineFlowed = false; - - resultLine.append(line); - } - } - - return result.toString(); - } -} 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 51eca9da5bd059c91a4a265daa6c0763fbc826da..cb6c2c2344893a694604ba4f19025b07dbfc360a 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 @@ -3,16 +3,20 @@ package com.fsck.k9.message; import android.net.Uri; import android.net.Uri.Builder; -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 { + private static final int MAX_LINE_LENGTH = 72; + private static final int FIRST_LINE_EXTRA_LENGTH = K9.IDENTITY_HEADER.length() + 2; + private InsertableHtmlContent quotedHtmlContent; private QuoteStyle quoteStyle; private SimpleMessageFormat messageFormat; @@ -95,9 +99,33 @@ public class IdentityHeaderBuilder { appendValue(IdentityField.QUOTED_TEXT_MODE, quotedTextMode); String k9identity = IdentityField.IDENTITY_VERSION_1 + uri.build().getEncodedQuery(); + String headerValue = foldHeaderValue(k9identity); + + Timber.d("Generated identity: %s", headerValue); + return headerValue; + } + + private String foldHeaderValue(String input) { + int inputLength = input.length(); + int endOfFirstLine = MAX_LINE_LENGTH - FIRST_LINE_EXTRA_LENGTH; + if (inputLength <= endOfFirstLine) { + return input; + } + + int extraLines = (inputLength - endOfFirstLine - 1) / (MAX_LINE_LENGTH - 1) + 1; + int builderSize = inputLength + extraLines * 3 /* CR LF SPACE */; + StringBuilder headerValue = new StringBuilder(builderSize); + + headerValue.append(input, 0, endOfFirstLine); + int start = endOfFirstLine; + while (start < inputLength) { + headerValue.append("\r\n "); + int end = start + Math.min(MAX_LINE_LENGTH - 1, inputLength - start); + headerValue.append(input, start, end); + start = end; + } - Timber.d("Generated identity: %s", k9identity); - return k9identity; + return headerValue.toString(); } private void appendValue(IdentityField field, int value) { diff --git a/app/core/src/main/java/com/fsck/k9/message/IdentityHeaderParser.java b/app/core/src/main/java/com/fsck/k9/message/IdentityHeaderParser.java index e73cea944de41cee98ddf8b84b3a35639860724a..14b97add02bb686523ae69f39ee46778f0d7df93 100644 --- a/app/core/src/main/java/com/fsck/k9/message/IdentityHeaderParser.java +++ b/app/core/src/main/java/com/fsck/k9/message/IdentityHeaderParser.java @@ -29,10 +29,12 @@ public class IdentityHeaderParser { return identity; } + String encodedString = unfoldHeaderValue(identityString); + // Check to see if this is a "next gen" identity. - if (identityString.charAt(0) == IdentityField.IDENTITY_VERSION_1.charAt(0) && identityString.length() > 2) { + if (encodedString.charAt(0) == IdentityField.IDENTITY_VERSION_1.charAt(0) && encodedString.length() > 2) { Uri.Builder builder = new Uri.Builder(); - builder.encodedQuery(identityString.substring(1)); // Need to cut off the ! at the beginning. + builder.encodedQuery(encodedString.substring(1)); // Need to cut off the ! at the beginning. Uri uri = builder.build(); for (IdentityField key : IdentityField.values()) { String value = uri.getQueryParameter(key.value()); @@ -56,9 +58,9 @@ public class IdentityHeaderParser { } else { // Legacy identity - Timber.d("Got a saved legacy identity: %s", identityString); + Timber.d("Got a saved legacy identity: %s", encodedString); - StringTokenizer tokenizer = new StringTokenizer(identityString, ":", false); + StringTokenizer tokenizer = new StringTokenizer(encodedString, ":", false); // First item is the body length. We use this to separate the composed reply from the quoted text. if (tokenizer.hasMoreTokens()) { @@ -85,4 +87,8 @@ public class IdentityHeaderParser { return identity; } + + private static String unfoldHeaderValue(String identityString) { + return identityString.replaceAll("\r?\n ", ""); + } } diff --git a/app/core/src/main/java/com/fsck/k9/message/MessageBuilder.java b/app/core/src/main/java/com/fsck/k9/message/MessageBuilder.java index 8ea190b154ef59350523fef7600bf269153a1b5f..0df05d2c8ec491de36943c47c769365a94e38f07 100644 --- a/app/core/src/main/java/com/fsck/k9/message/MessageBuilder.java +++ b/app/core/src/main/java/com/fsck/k9/message/MessageBuilder.java @@ -50,6 +50,7 @@ public abstract class MessageBuilder { private Address[] to; private Address[] cc; private Address[] bcc; + private Address[] replyTo; private String inReplyTo; private String references; private boolean requestReadReceipt; @@ -115,10 +116,7 @@ public abstract class MessageBuilder { message.setHeader("User-Agent", encodedUserAgent); } - final String replyTo = identity.getReplyTo(); - if (replyTo != null) { - message.setReplyTo(new Address[] { new Address(replyTo) }); - } + message.setReplyTo(replyTo); if (inReplyTo != null) { message.setInReplyTo(inReplyTo); @@ -395,6 +393,11 @@ public abstract class MessageBuilder { return this; } + public MessageBuilder setReplyTo(Address[] replyTo) { + this.replyTo = replyTo; + return this; + } + public MessageBuilder setInReplyTo(String inReplyTo) { this.inReplyTo = inReplyTo; return this; 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 b571abece8dd17612a542f997e462679e9f941ed..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" @@ -38,11 +37,11 @@ class BasicPartInfoExtractor { private fun String.toMimeValue(): MimeValue = MimeParameterDecoder.decode(this) - private fun MimeValue.getParameter(name: String): String? = parameters[name.toLowerCase(Locale.ROOT)] + private fun MimeValue.getParameter(name: String): String? = parameters[name.lowercase()] private fun String.getParameter(name: String): String? { val mimeValue = MimeParameterDecoder.decode(this) - return mimeValue.parameters[name.toLowerCase(Locale.ROOT)] + return mimeValue.parameters[name.lowercase()] } } 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 ee5b44344d3191b2e19b5ef45143b01b1c22c8b8..5dea02560e4928e60caeffd8a5930f8d3755fbc6 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,9 +1,8 @@ package com.fsck.k9.message.html -import java.util.Locale - object UriMatcher { - private val SUPPORTED_URIS = { httpUriParser: HttpUriParser -> + private val SUPPORTED_URIS = run { + val httpUriParser = HttpUriParser() mapOf( "ethereum:" to EthereumUriParser(), "bitcoin:" to BitcoinUriParser(), @@ -11,7 +10,7 @@ object UriMatcher { "https:" to httpUriParser, "rtsp:" to httpUriParser ) - }.invoke(HttpUriParser()) + } private const val SCHEME_SEPARATORS = "\\s(\\n<" private const val ALLOWED_SEPARATORS_PATTERN = "(?:^|[$SCHEME_SEPARATORS])" @@ -23,8 +22,8 @@ object UriMatcher { fun findUris(text: CharSequence): List { return URI_SCHEME.findAll(text).map { matchResult -> val matchGroup = matchResult.groups[1]!! - val startIndex = matchGroup.range.start - val scheme = matchGroup.value.toLowerCase(Locale.ROOT) + val startIndex = matchGroup.range.first + val scheme = matchGroup.value.lowercase() val parser = SUPPORTED_URIS[scheme] ?: throw AssertionError("Scheme not found: $scheme") parser.parseUri(text, startIndex) diff --git a/app/core/src/main/java/com/fsck/k9/notification/AddNotificationResult.java b/app/core/src/main/java/com/fsck/k9/notification/AddNotificationResult.java deleted file mode 100644 index 9dd3257fa1a025497a71cddda1fa9a5370c332db..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/notification/AddNotificationResult.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.fsck.k9.notification; - - -class AddNotificationResult { - private final NotificationHolder notificationHolder; - private final boolean cancelNotificationBeforeReuse; - - - private AddNotificationResult(NotificationHolder notificationHolder, - boolean cancelNotificationBeforeReuse) { - this.notificationHolder = notificationHolder; - this.cancelNotificationBeforeReuse = cancelNotificationBeforeReuse; - } - - public static AddNotificationResult newNotification(NotificationHolder notificationHolder) { - return new AddNotificationResult(notificationHolder, false); - } - - public static AddNotificationResult replaceNotification(NotificationHolder notificationHolder) { - return new AddNotificationResult(notificationHolder, true); - } - - public boolean shouldCancelNotification() { - return cancelNotificationBeforeReuse; - } - - public int getNotificationId() { - if (!shouldCancelNotification()) { - throw new IllegalStateException("getNotificationId() can only be called when " + - "shouldCancelNotification() returns true"); - } - - return notificationHolder.notificationId; - } - - public NotificationHolder getNotificationHolder() { - return notificationHolder; - } -} diff --git a/app/core/src/main/java/com/fsck/k9/notification/AddNotificationResult.kt b/app/core/src/main/java/com/fsck/k9/notification/AddNotificationResult.kt new file mode 100644 index 0000000000000000000000000000000000000000..662235eb2f9714bd308c58eab09b0a426f15992a --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/AddNotificationResult.kt @@ -0,0 +1,42 @@ +package com.fsck.k9.notification + +internal class AddNotificationResult private constructor( + val notificationData: NotificationData, + val notificationStoreOperations: List, + val notificationHolder: NotificationHolder, + val shouldCancelNotification: Boolean +) { + val cancelNotificationId: Int + get() { + check(shouldCancelNotification) { "shouldCancelNotification == false" } + return notificationHolder.notificationId + } + + companion object { + fun newNotification( + notificationData: NotificationData, + notificationStoreOperations: List, + notificationHolder: NotificationHolder + ): AddNotificationResult { + return AddNotificationResult( + notificationData, + notificationStoreOperations, + notificationHolder, + shouldCancelNotification = false + ) + } + + fun replaceNotification( + notificationData: NotificationData, + notificationStoreOperations: List, + notificationHolder: NotificationHolder + ): AddNotificationResult { + return AddNotificationResult( + notificationData, + notificationStoreOperations, + notificationHolder, + shouldCancelNotification = true + ) + } + } +} 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..fad5368c95f9e1e9a72b5a6465592653274fed70 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/AuthenticationErrorNotificationController.kt @@ -0,0 +1,71 @@ +package com.fsck.k9.notification + +import android.app.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) + .setColor(account.chipColor) + .setWhen(System.currentTimeMillis()) + .setAutoCancel(true) + .setTicker(title) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(editServerSettingsPendingIntent) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setPublicVersion(createLockScreenNotification(account)) + .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 fun createLockScreenNotification(account: Account): Notification { + return notificationHelper + .createNotificationBuilder(account, NotificationChannelManager.ChannelType.MISCELLANEOUS) + .setSmallIcon(resourceProvider.iconWarning) + .setColor(account.chipColor) + .setWhen(System.currentTimeMillis()) + .setContentTitle(resourceProvider.authenticationErrorTitle()) + .build() + } + + private val notificationManager: NotificationManagerCompat + get() = notificationHelper.getNotificationManager() +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/AuthenticationErrorNotifications.java b/app/core/src/main/java/com/fsck/k9/notification/AuthenticationErrorNotifications.java deleted file mode 100644 index 302199d4da6f021ebc595f5c0d0fc50746addca5..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/notification/AuthenticationErrorNotifications.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.fsck.k9.notification; - - -import android.app.PendingIntent; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationCompat.BigTextStyle; -import androidx.core.app.NotificationManagerCompat; - -import com.fsck.k9.Account; - -import static com.fsck.k9.notification.NotificationHelper.NOTIFICATION_LED_BLINK_FAST; -import static com.fsck.k9.notification.NotificationHelper.NOTIFICATION_LED_FAILURE_COLOR; - - -class AuthenticationErrorNotifications { - private final NotificationHelper notificationHelper; - private final NotificationActionCreator actionCreator; - private final NotificationResourceProvider resourceProvider; - - - public AuthenticationErrorNotifications(NotificationHelper notificationHelper, - NotificationActionCreator actionCreator, NotificationResourceProvider resourceProvider) { - this.notificationHelper = notificationHelper; - this.actionCreator = actionCreator; - this.resourceProvider = resourceProvider; - } - - public void showAuthenticationErrorNotification(Account account, boolean incoming) { - int notificationId = NotificationIds.getAuthenticationErrorNotificationId(account, incoming); - - PendingIntent editServerSettingsPendingIntent = createContentIntent(account, incoming); - String title = resourceProvider.authenticationErrorTitle(); - String text = resourceProvider.authenticationErrorBody(account.getDescription()); - - NotificationCompat.Builder builder = notificationHelper - .createNotificationBuilder(account, NotificationChannelManager.ChannelType.MISCELLANEOUS) - .setSmallIcon(resourceProvider.getIconWarning()) - .setWhen(System.currentTimeMillis()) - .setAutoCancel(true) - .setTicker(title) - .setContentTitle(title) - .setContentText(text) - .setContentIntent(editServerSettingsPendingIntent) - .setStyle(new BigTextStyle().bigText(text)) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setCategory(NotificationCompat.CATEGORY_ERROR); - - notificationHelper.configureNotification(builder, null, null, - NOTIFICATION_LED_FAILURE_COLOR, - NOTIFICATION_LED_BLINK_FAST, true); - - getNotificationManager().notify(notificationId, builder.build()); - } - - public void clearAuthenticationErrorNotification(Account account, boolean incoming) { - int notificationId = NotificationIds.getAuthenticationErrorNotificationId(account, incoming); - getNotificationManager().cancel(notificationId); - } - - PendingIntent createContentIntent(Account account, boolean incoming) { - return incoming ? actionCreator.getEditIncomingServerSettingsIntent(account) : - actionCreator.getEditOutgoingServerSettingsIntent(account); - } - - private NotificationManagerCompat getNotificationManager() { - return 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..936f884c4dd10dee9d0a579e8b1d13e3e890412e --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/BaseNotificationDataCreator.kt @@ -0,0 +1,53 @@ +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.activeNotifications.asSequence() + .map { it.content.sender } + .distinct() + .take(MAX_NUMBER_OF_SENDERS_IN_LOCK_SCREEN_NOTIFICATION) + .joinToString() + } + + private fun createNotificationAppearance(account: Account): NotificationAppearance { + return with(account.notificationSetting) { + val vibrationPattern = if (isVibrateEnabled) vibration else null + NotificationAppearance(ringtone, vibrationPattern, ledColor) + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/BaseNotifications.java b/app/core/src/main/java/com/fsck/k9/notification/BaseNotifications.java deleted file mode 100644 index 5534372de0085aaf11e07894dc1671764cd04007..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/notification/BaseNotifications.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.fsck.k9.notification; - - -import android.app.PendingIntent; -import android.content.Context; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationCompat.BigTextStyle; -import androidx.core.app.NotificationCompat.Builder; - -import com.fsck.k9.Account; -import com.fsck.k9.K9; -import com.fsck.k9.K9.NotificationQuickDelete; - - -abstract class BaseNotifications { - protected final Context context; - protected final NotificationHelper notificationHelper; - protected final NotificationActionCreator actionCreator; - protected final NotificationResourceProvider resourceProvider; - - - protected BaseNotifications(NotificationHelper notificationHelper, NotificationActionCreator actionCreator, - NotificationResourceProvider resourceProvider) { - this.context = notificationHelper.getContext(); - this.notificationHelper = notificationHelper; - this.actionCreator = actionCreator; - this.resourceProvider = resourceProvider; - } - - protected NotificationCompat.Builder createBigTextStyleNotification(Account account, NotificationHolder holder, - int notificationId) { - String accountName = notificationHelper.getAccountName(account); - NotificationContent content = holder.content; - String groupKey = NotificationGroupKeys.getGroupKey(account); - - NotificationCompat.Builder builder = createAndInitializeNotificationBuilder(account) - .setTicker(content.summary) - .setGroup(groupKey) - .setContentTitle(content.sender) - .setContentText(content.subject) - .setSubText(accountName); - - NotificationCompat.BigTextStyle style = createBigTextStyle(builder); - style.bigText(content.preview); - - builder.setStyle(style); - - PendingIntent contentIntent = actionCreator.createViewMessagePendingIntent( - content.messageReference, notificationId); - builder.setContentIntent(contentIntent); - - return builder; - } - - protected NotificationCompat.Builder createAndInitializeNotificationBuilder(Account account) { - return notificationHelper.createNotificationBuilder(account, - NotificationChannelManager.ChannelType.MESSAGES) - .setSmallIcon(getNewMailNotificationIcon()) - .setColor(account.getChipColor()) - .setWhen(System.currentTimeMillis()) - .setAutoCancel(true) - .setCategory(NotificationCompat.CATEGORY_EMAIL); - } - - protected boolean isDeleteActionEnabled() { - NotificationQuickDelete deleteOption = K9.getNotificationQuickDeleteBehaviour(); - return deleteOption == NotificationQuickDelete.ALWAYS || deleteOption == NotificationQuickDelete.FOR_SINGLE_MSG; - } - - protected BigTextStyle createBigTextStyle(Builder builder) { - return new BigTextStyle(builder); - } - - private int getNewMailNotificationIcon() { - return resourceProvider.getIconNewMail(); - } -} 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..e787b85efbaa09c9ec7da7424dc48438599b138b --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/CertificateErrorNotificationController.kt @@ -0,0 +1,71 @@ +package com.fsck.k9.notification + +import android.app.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) + .setColor(account.chipColor) + .setWhen(System.currentTimeMillis()) + .setAutoCancel(true) + .setTicker(title) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(editServerSettingsPendingIntent) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setPublicVersion(createLockScreenNotification(account)) + .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 fun createLockScreenNotification(account: Account): Notification { + return notificationHelper + .createNotificationBuilder(account, NotificationChannelManager.ChannelType.MISCELLANEOUS) + .setSmallIcon(resourceProvider.iconWarning) + .setColor(account.chipColor) + .setWhen(System.currentTimeMillis()) + .setContentTitle(resourceProvider.certificateErrorTitle()) + .build() + } + + private val notificationManager: NotificationManagerCompat + get() = notificationHelper.getNotificationManager() +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/CertificateErrorNotifications.java b/app/core/src/main/java/com/fsck/k9/notification/CertificateErrorNotifications.java deleted file mode 100644 index a5239b1634abfc8f311048c0ad162083ef344243..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/notification/CertificateErrorNotifications.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.fsck.k9.notification; - - -import android.app.PendingIntent; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationCompat.BigTextStyle; -import androidx.core.app.NotificationManagerCompat; - -import com.fsck.k9.Account; - -import static com.fsck.k9.notification.NotificationHelper.NOTIFICATION_LED_BLINK_FAST; -import static com.fsck.k9.notification.NotificationHelper.NOTIFICATION_LED_FAILURE_COLOR; - - -class CertificateErrorNotifications { - private final NotificationHelper notificationHelper; - private final NotificationActionCreator actionCreator; - private final NotificationResourceProvider resourceProvider; - - - public CertificateErrorNotifications(NotificationHelper notificationHelper, - NotificationActionCreator actionCreator, NotificationResourceProvider resourceProvider) { - this.notificationHelper = notificationHelper; - this.actionCreator = actionCreator; - this.resourceProvider = resourceProvider; - } - - public void showCertificateErrorNotification(Account account, boolean incoming) { - int notificationId = NotificationIds.getCertificateErrorNotificationId(account, incoming); - - PendingIntent editServerSettingsPendingIntent = createContentIntent(account, incoming); - String title = resourceProvider.certificateErrorTitle(account.getDescription()); - String text = resourceProvider.certificateErrorBody(); - - NotificationCompat.Builder builder = notificationHelper - .createNotificationBuilder(account, NotificationChannelManager.ChannelType.MISCELLANEOUS) - .setSmallIcon(resourceProvider.getIconWarning()) - .setWhen(System.currentTimeMillis()) - .setAutoCancel(true) - .setTicker(title) - .setContentTitle(title) - .setContentText(text) - .setContentIntent(editServerSettingsPendingIntent) - .setStyle(new BigTextStyle().bigText(text)) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setCategory(NotificationCompat.CATEGORY_ERROR); - - notificationHelper.configureNotification(builder, null, null, - NOTIFICATION_LED_FAILURE_COLOR, - NOTIFICATION_LED_BLINK_FAST, true); - - getNotificationManager().notify(notificationId, builder.build()); - } - - public void clearCertificateErrorNotifications(Account account, boolean incoming) { - int notificationId = NotificationIds.getCertificateErrorNotificationId(account, incoming); - getNotificationManager().cancel(notificationId); - } - - PendingIntent createContentIntent(Account account, boolean incoming) { - return incoming ? - actionCreator.getEditIncomingServerSettingsIntent(account) : - actionCreator.getEditOutgoingServerSettingsIntent(account); - } - - private NotificationManagerCompat getNotificationManager() { - return 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..dba15df01c6a5cf718223dd1e12c7479fe2227bf 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,68 @@ 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(), + notificationRepository = 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(), @@ -43,4 +104,12 @@ val coreNotificationModule = module { notificationManager = get() ) } + single { + NotificationRepository( + notificationStoreProvider = get(), + localStoreProvider = get(), + messageStoreManager = get(), + notificationContentCreator = get() + ) + } } diff --git a/app/core/src/main/java/com/fsck/k9/notification/DeviceNotifications.java b/app/core/src/main/java/com/fsck/k9/notification/DeviceNotifications.java deleted file mode 100644 index d9b7af54b01e33e2193c4eb287c66b03d84ba5a7..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/notification/DeviceNotifications.java +++ /dev/null @@ -1,235 +0,0 @@ -package com.fsck.k9.notification; - - -import java.util.ArrayList; -import java.util.List; - -import android.app.KeyguardManager; -import android.app.Notification; -import android.app.PendingIntent; -import android.content.Context; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationCompat.Builder; -import androidx.core.app.NotificationCompat.InboxStyle; - -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.NotificationSetting; -import com.fsck.k9.controller.MessageReference; - -import static com.fsck.k9.notification.NotificationHelper.NOTIFICATION_LED_BLINK_SLOW; - - -class DeviceNotifications extends BaseNotifications { - private final WearNotifications wearNotifications; - private final LockScreenNotification lockScreenNotification; - - - DeviceNotifications(NotificationHelper notificationHelper, NotificationActionCreator actionCreator, - LockScreenNotification lockScreenNotification, WearNotifications wearNotifications, - NotificationResourceProvider resourceProvider) { - super(notificationHelper, actionCreator, resourceProvider); - this.wearNotifications = wearNotifications; - this.lockScreenNotification = lockScreenNotification; - } - - public Notification buildSummaryNotification(Account account, NotificationData notificationData, - boolean silent) { - int unreadMessageCount = notificationData.getUnreadMessageCount(); - - NotificationCompat.Builder builder; - if (isPrivacyModeActive()) { - builder = createSimpleSummaryNotification(account, unreadMessageCount); - } else if (notificationData.isSingleMessageNotification()) { - NotificationHolder holder = notificationData.getHolderForLatestNotification(); - builder = createBigTextStyleSummaryNotification(account, holder); - } else { - builder = createInboxStyleSummaryNotification(account, notificationData, unreadMessageCount); - } - - if (notificationData.containsStarredMessages()) { - builder.setPriority(NotificationCompat.PRIORITY_HIGH); - } - - int notificationId = NotificationIds.getNewMailSummaryNotificationId(account); - PendingIntent deletePendingIntent = actionCreator.createDismissAllMessagesPendingIntent( - account, notificationId); - builder.setDeleteIntent(deletePendingIntent); - - lockScreenNotification.configureLockScreenNotification(builder, notificationData); - - boolean ringAndVibrate = false; - if (!silent && !account.isRingNotified()) { - account.setRingNotified(true); - ringAndVibrate = true; - } - - NotificationSetting notificationSetting = account.getNotificationSetting(); - notificationHelper.configureNotification( - builder, - (notificationSetting.isRingEnabled()) ? notificationSetting.getRingtone() : null, - (notificationSetting.isVibrateEnabled()) ? notificationSetting.getVibration() : null, - (notificationSetting.isLedEnabled()) ? notificationSetting.getLedColor() : null, - NOTIFICATION_LED_BLINK_SLOW, - ringAndVibrate); - - return builder.build(); - } - - private NotificationCompat.Builder createSimpleSummaryNotification(Account account, int unreadMessageCount) { - String accountName = notificationHelper.getAccountName(account); - CharSequence newMailText = resourceProvider.newMailTitle(); - String unreadMessageCountText = resourceProvider.newMailUnreadMessageCount(unreadMessageCount, accountName); - - int notificationId = NotificationIds.getNewMailSummaryNotificationId(account); - PendingIntent contentIntent = actionCreator.createViewFolderListPendingIntent(account, notificationId); - - return createAndInitializeNotificationBuilder(account) - .setNumber(unreadMessageCount) - .setTicker(newMailText) - .setContentTitle(unreadMessageCountText) - .setContentText(newMailText) - .setContentIntent(contentIntent); - } - - private NotificationCompat.Builder createBigTextStyleSummaryNotification(Account account, - NotificationHolder holder) { - - int notificationId = NotificationIds.getNewMailSummaryNotificationId(account); - Builder builder = createBigTextStyleNotification(account, holder, notificationId) - .setGroupSummary(true); - - NotificationContent content = holder.content; - addReplyAction(builder, content, notificationId); - addMarkAsReadAction(builder, content, notificationId); - addDeleteAction(builder, content, notificationId); - - return builder; - } - - private NotificationCompat.Builder createInboxStyleSummaryNotification(Account account, - NotificationData notificationData, int unreadMessageCount) { - - NotificationHolder latestNotification = notificationData.getHolderForLatestNotification(); - - int newMessagesCount = notificationData.getNewMessagesCount(); - String accountName = notificationHelper.getAccountName(account); - String title = resourceProvider.newMessagesTitle(newMessagesCount); - String summary = (notificationData.hasSummaryOverflowMessages()) ? - resourceProvider.additionalMessages(notificationData.getSummaryOverflowMessagesCount(), accountName) : - accountName; - String groupKey = NotificationGroupKeys.getGroupKey(account); - - NotificationCompat.Builder builder = createAndInitializeNotificationBuilder(account) - .setNumber(unreadMessageCount) - .setTicker(latestNotification.content.summary) - .setGroup(groupKey) - .setGroupSummary(true) - .setContentTitle(title) - .setSubText(accountName); - - NotificationCompat.InboxStyle style = createInboxStyle(builder) - .setBigContentTitle(title) - .setSummaryText(summary); - - for (NotificationContent content : notificationData.getContentForSummaryNotification()) { - style.addLine(content.summary); - } - - builder.setStyle(style); - - addMarkAllAsReadAction(builder, notificationData); - addDeleteAllAction(builder, notificationData); - - wearNotifications.addSummaryActions(builder, notificationData); - - int notificationId = NotificationIds.getNewMailSummaryNotificationId(account); - List messageReferences = notificationData.getAllMessageReferences(); - PendingIntent contentIntent = actionCreator.createViewMessagesPendingIntent( - account, messageReferences, notificationId); - builder.setContentIntent(contentIntent); - - return builder; - } - - private void addMarkAsReadAction(Builder builder, NotificationContent content, int notificationId) { - int icon = resourceProvider.getIconMarkAsRead(); - String title = resourceProvider.actionMarkAsRead(); - - - MessageReference messageReference = content.messageReference; - PendingIntent action = actionCreator.createMarkMessageAsReadPendingIntent(messageReference, notificationId); - - builder.addAction(icon, title, action); - } - - private void addMarkAllAsReadAction(Builder builder, NotificationData notificationData) { - int icon = resourceProvider.getIconMarkAsRead(); - String title = resourceProvider.actionMarkAsRead(); - - Account account = notificationData.getAccount(); - ArrayList messageReferences = notificationData.getAllMessageReferences(); - int notificationId = NotificationIds.getNewMailSummaryNotificationId(account); - PendingIntent markAllAsReadPendingIntent = - actionCreator.createMarkAllAsReadPendingIntent(account, messageReferences, notificationId); - - builder.addAction(icon, title, markAllAsReadPendingIntent); - } - - private void addDeleteAllAction(Builder builder, NotificationData notificationData) { - if (K9.getNotificationQuickDeleteBehaviour() != NotificationQuickDelete.ALWAYS) { - return; - } - - int icon = resourceProvider.getIconDelete(); - String title = resourceProvider.actionDelete(); - - Account account = notificationData.getAccount(); - int notificationId = NotificationIds.getNewMailSummaryNotificationId(account); - ArrayList messageReferences = notificationData.getAllMessageReferences(); - PendingIntent action = actionCreator.createDeleteAllPendingIntent(account, messageReferences, notificationId); - - builder.addAction(icon, title, action); - } - - private void addDeleteAction(Builder builder, NotificationContent content, int notificationId) { - if (!isDeleteActionEnabled()) { - return; - } - - int icon = resourceProvider.getIconDelete(); - String title = resourceProvider.actionDelete(); - - MessageReference messageReference = content.messageReference; - PendingIntent action = actionCreator.createDeleteMessagePendingIntent(messageReference, notificationId); - - builder.addAction(icon, title, action); - } - - private void addReplyAction(Builder builder, NotificationContent content, int notificationId) { - int icon = resourceProvider.getIconReply(); - String title = resourceProvider.actionReply(); - - MessageReference messageReference = content.messageReference; - PendingIntent replyToMessagePendingIntent = - actionCreator.createReplyPendingIntent(messageReference, notificationId); - - builder.addAction(icon, title, replyToMessagePendingIntent); - } - - private boolean isPrivacyModeActive() { - KeyguardManager keyguardService = (KeyguardManager) context.getSystemService(Context.KEYGUARD_SERVICE); - - boolean privacyModeAlwaysEnabled = K9.getNotificationHideSubject() == NotificationHideSubject.ALWAYS; - boolean privacyModeEnabledWhenLocked = K9.getNotificationHideSubject() == NotificationHideSubject.WHEN_LOCKED; - boolean screenLocked = keyguardService.inKeyguardRestrictedInputMode(); - - return privacyModeAlwaysEnabled || (privacyModeEnabledWhenLocked && screenLocked); - } - - protected InboxStyle createInboxStyle(Builder builder) { - return new InboxStyle(builder); - } -} diff --git a/app/core/src/main/java/com/fsck/k9/notification/LockScreenNotification.java b/app/core/src/main/java/com/fsck/k9/notification/LockScreenNotification.java deleted file mode 100644 index 3884f3276273f98269282ac6521363c1a00c7361..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/notification/LockScreenNotification.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.fsck.k9.notification; - - -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; - -import android.app.Notification; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationCompat.Builder; -import android.text.TextUtils; - -import com.fsck.k9.Account; -import com.fsck.k9.K9; - - -class LockScreenNotification { - static final int MAX_NUMBER_OF_SENDERS_IN_LOCK_SCREEN_NOTIFICATION = 5; - - - private final NotificationHelper notificationHelper; - private final NotificationResourceProvider resourceProvider; - - - LockScreenNotification(NotificationHelper notificationHelper, NotificationResourceProvider resourceProvider) { - this.notificationHelper = notificationHelper; - this.resourceProvider = resourceProvider; - } - - public void configureLockScreenNotification(Builder builder, NotificationData notificationData) { - switch (K9.getLockScreenNotificationVisibility()) { - case NOTHING: { - builder.setVisibility(NotificationCompat.VISIBILITY_SECRET); - break; - } - case APP_NAME: { - builder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE); - break; - } - case EVERYTHING: { - builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC); - break; - } - case SENDERS: { - Notification publicNotification = createPublicNotificationWithSenderList(notificationData); - builder.setPublicVersion(publicNotification); - break; - } - case MESSAGE_COUNT: { - Notification publicNotification = createPublicNotificationWithNewMessagesCount(notificationData); - builder.setPublicVersion(publicNotification); - break; - } - } - } - - private Notification createPublicNotificationWithSenderList(NotificationData notificationData) { - Builder builder = createPublicNotification(notificationData); - int newMessages = notificationData.getNewMessagesCount(); - if (newMessages == 1) { - NotificationHolder holder = notificationData.getHolderForLatestNotification(); - builder.setContentText(holder.content.sender); - } else { - List contents = notificationData.getContentForSummaryNotification(); - String senderList = createCommaSeparatedListOfSenders(contents); - builder.setContentText(senderList); - } - - return builder.build(); - } - - private Notification createPublicNotificationWithNewMessagesCount(NotificationData notificationData) { - Builder builder = createPublicNotification(notificationData); - Account account = notificationData.getAccount(); - String accountName = notificationHelper.getAccountName(account); - builder.setContentText(accountName); - - return builder.build(); - } - - private Builder createPublicNotification(NotificationData notificationData) { - Account account = notificationData.getAccount(); - int newMessages = notificationData.getNewMessagesCount(); - int unreadCount = notificationData.getUnreadMessageCount(); - String title = resourceProvider.newMessagesTitle(newMessages); - - return notificationHelper.createNotificationBuilder(account, - NotificationChannelManager.ChannelType.MESSAGES) - .setSmallIcon(resourceProvider.getIconNewMail()) - .setColor(account.getChipColor()) - .setNumber(unreadCount) - .setContentTitle(title) - .setCategory(NotificationCompat.CATEGORY_EMAIL); - } - - - String createCommaSeparatedListOfSenders(List contents) { - // Use a LinkedHashSet so that we preserve ordering (newest to oldest), but still remove duplicates - Set senders = new LinkedHashSet<>(MAX_NUMBER_OF_SENDERS_IN_LOCK_SCREEN_NOTIFICATION); - for (NotificationContent content : contents) { - senders.add(content.sender); - if (senders.size() == MAX_NUMBER_OF_SENDERS_IN_LOCK_SCREEN_NOTIFICATION) { - break; - } - } - - return TextUtils.join(", ", senders); - } -} 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/NewMailNotificationController.kt b/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationController.kt new file mode 100644 index 0000000000000000000000000000000000000000..190144862113200f25529bceef710962cc23c44e --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationController.kt @@ -0,0 +1,86 @@ +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 restoreNewMailNotifications(accounts: List) { + for (account in accounts) { + val notificationData = newMailNotificationManager.restoreNewMailNotifications(account) + + if (notificationData != null) { + processNewMailNotificationData(notificationData) + } + } + } + + fun addNewMailNotification(account: Account, message: LocalMessage, silent: Boolean) { + val notificationData = newMailNotificationManager.addNewMailNotification(account, message, silent) + + processNewMailNotificationData(notificationData) + } + + fun removeNewMailNotifications( + account: Account, + clearNewMessageState: Boolean, + selector: (List) -> List + ) { + val notificationData = newMailNotificationManager.removeNewMailNotifications( + account, + clearNewMessageState, + selector + ) + + if (notificationData != null) { + processNewMailNotificationData(notificationData) + } + } + + fun clearNewMailNotifications(account: Account, clearNewMessageState: Boolean) { + val cancelNotificationIds = newMailNotificationManager.clearNewMailNotifications(account, clearNewMessageState) + + 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..8e1a8c3fe2645aacaaf5b48b7d083b458f862ffa --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NewMailNotificationManager.kt @@ -0,0 +1,134 @@ +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 notificationRepository: NotificationRepository, + private val baseNotificationDataCreator: BaseNotificationDataCreator, + private val singleMessageNotificationDataCreator: SingleMessageNotificationDataCreator, + private val summaryNotificationDataCreator: SummaryNotificationDataCreator, + private val clock: Clock +) { + fun restoreNewMailNotifications(account: Account): NewMailNotificationData? { + val notificationData = notificationRepository.restoreNotifications(account) ?: return null + + val addLockScreenNotification = notificationData.isSingleMessageNotification + val singleNotificationDataList = notificationData.activeNotifications.map { notificationHolder -> + createSingleNotificationData( + account = account, + notificationId = notificationHolder.notificationId, + content = notificationHolder.content, + timestamp = notificationHolder.timestamp, + addLockScreenNotification = addLockScreenNotification + ) + } + + return NewMailNotificationData( + cancelNotificationIds = emptyList(), + baseNotificationData = createBaseNotificationData(notificationData), + singleNotificationData = singleNotificationDataList, + summaryNotificationData = createSummaryNotificationData(notificationData, silent = true) + ) + } + + fun addNewMailNotification(account: Account, message: LocalMessage, silent: Boolean): NewMailNotificationData { + val content = contentCreator.createFromMessage(account, message) + + val result = notificationRepository.addNotification(account, content, timestamp = now()) + + val singleNotificationData = createSingleNotificationData( + account = account, + notificationId = result.notificationHolder.notificationId, + content = result.notificationHolder.content, + timestamp = result.notificationHolder.timestamp, + addLockScreenNotification = result.notificationData.isSingleMessageNotification + ) + + return NewMailNotificationData( + cancelNotificationIds = if (result.shouldCancelNotification) { + listOf(result.cancelNotificationId) + } else { + emptyList() + }, + baseNotificationData = createBaseNotificationData(result.notificationData), + singleNotificationData = listOf(singleNotificationData), + summaryNotificationData = createSummaryNotificationData(result.notificationData, silent) + ) + } + + fun removeNewMailNotifications( + account: Account, + clearNewMessageState: Boolean, + selector: (List) -> List + ): NewMailNotificationData? { + val result = notificationRepository.removeNotifications(account, clearNewMessageState, selector) ?: return null + + val cancelNotificationIds = when { + result.notificationData.isEmpty() -> { + result.cancelNotificationIds + NotificationIds.getNewMailSummaryNotificationId(account) + } + else -> { + result.cancelNotificationIds + } + } + + val singleNotificationData = result.notificationHolders.map { notificationHolder -> + createSingleNotificationData( + account = account, + notificationId = notificationHolder.notificationId, + content = notificationHolder.content, + timestamp = notificationHolder.timestamp, + addLockScreenNotification = result.notificationData.isSingleMessageNotification + ) + } + + return NewMailNotificationData( + cancelNotificationIds = cancelNotificationIds, + baseNotificationData = createBaseNotificationData(result.notificationData), + singleNotificationData = singleNotificationData, + summaryNotificationData = createSummaryNotificationData(result.notificationData, silent = true) + ) + } + + fun clearNewMailNotifications(account: Account, clearNewMessageState: Boolean): List { + notificationRepository.clearNotifications(account, clearNewMessageState) + return NotificationIds.getAllMessageNotificationIds(account) + } + + private fun createBaseNotificationData(notificationData: NotificationData): BaseNotificationData { + return baseNotificationDataCreator.createBaseNotificationData(notificationData) + } + + private fun createSingleNotificationData( + account: Account, + notificationId: Int, + content: NotificationContent, + timestamp: Long, + addLockScreenNotification: Boolean + ): SingleNotificationData { + return singleMessageNotificationDataCreator.createSingleNotificationData( + account, + notificationId, + content, + timestamp, + addLockScreenNotification + ) + } + + private fun createSummaryNotificationData(data: NotificationData, silent: Boolean): SummaryNotificationData? { + return if (data.isEmpty()) { + null + } else { + summaryNotificationDataCreator.createSummaryNotificationData(data, silent) + } + } + + private fun now(): Long = clock.time +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NewMailNotifications.java b/app/core/src/main/java/com/fsck/k9/notification/NewMailNotifications.java deleted file mode 100644 index 8fc8ae5e270abcbce8e4f9911891b6c68a4e50d8..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/notification/NewMailNotifications.java +++ /dev/null @@ -1,167 +0,0 @@ -package com.fsck.k9.notification; - - -import android.app.Notification; -import androidx.core.app.NotificationManagerCompat; -import android.util.SparseArray; - -import com.fsck.k9.Account; -import com.fsck.k9.K9; -import com.fsck.k9.K9.NotificationHideSubject; -import com.fsck.k9.controller.MessageReference; -import com.fsck.k9.mailstore.LocalMessage; - - -/** - * Handle notifications for new messages. - *

- * We call the notification shown on the device summary notification, even when there's only one new message. - * Notifications on an Android Wear device are displayed as a stack of cards and that's why we call them stacked - * notifications. We have to keep track of stacked notifications individually and recreate/update the summary - * notification when one or more of the stacked notifications are added/removed.
- * {@link NotificationData} keeps track of all data required to (re)create the actual system notifications. - *

- */ -class NewMailNotifications { - private final NotificationHelper notificationHelper; - private final NotificationContentCreator contentCreator; - private final DeviceNotifications deviceNotifications; - private final WearNotifications wearNotifications; - private final SparseArray notifications = new SparseArray<>(); - private final Object lock = new Object(); - - - NewMailNotifications(NotificationHelper notificationHelper, NotificationContentCreator contentCreator, - DeviceNotifications deviceNotifications, WearNotifications wearNotifications) { - this.notificationHelper = notificationHelper; - this.deviceNotifications = deviceNotifications; - this.wearNotifications = wearNotifications; - this.contentCreator = contentCreator; - } - - public void addNewMailNotification(Account account, LocalMessage message, int unreadMessageCount) { - NotificationContent content = contentCreator.createFromMessage(account, message); - - synchronized (lock) { - NotificationData notificationData = getOrCreateNotificationData(account, unreadMessageCount); - AddNotificationResult result = notificationData.addNotificationContent(content); - - if (result.shouldCancelNotification()) { - int notificationId = result.getNotificationId(); - cancelNotification(notificationId); - } - - createStackedNotification(account, result.getNotificationHolder()); - createSummaryNotification(account, notificationData, false); - } - } - - public void removeNewMailNotification(Account account, MessageReference messageReference) { - synchronized (lock) { - NotificationData notificationData = getNotificationData(account); - if (notificationData == null) { - return; - } - - RemoveNotificationResult result = notificationData.removeNotificationForMessage(messageReference); - if (result.isUnknownNotification()) { - return; - } - - cancelNotification(result.getNotificationId()); - - if (result.shouldCreateNotification()) { - createStackedNotification(account, result.getNotificationHolder()); - } - - updateSummaryNotification(account, notificationData); - } - } - - public void clearNewMailNotifications(Account account) { - NotificationData notificationData; - synchronized (lock) { - notificationData = removeNotificationData(account); - } - - if (notificationData == null) { - return; - } - - for (int notificationId : notificationData.getActiveNotificationIds()) { - cancelNotification(notificationId); - } - - int notificationId = NotificationIds.getNewMailSummaryNotificationId(account); - cancelNotification(notificationId); - } - - private NotificationData getOrCreateNotificationData(Account account, int unreadMessageCount) { - NotificationData notificationData = getNotificationData(account); - if (notificationData != null) { - return notificationData; - } - - int accountNumber = account.getAccountNumber(); - NotificationData newNotificationHolder = createNotificationData(account, unreadMessageCount); - notifications.put(accountNumber, newNotificationHolder); - - return newNotificationHolder; - } - - private NotificationData getNotificationData(Account account) { - int accountNumber = account.getAccountNumber(); - return notifications.get(accountNumber); - } - - private NotificationData removeNotificationData(Account account) { - int accountNumber = account.getAccountNumber(); - NotificationData notificationData = notifications.get(accountNumber); - notifications.remove(accountNumber); - return notificationData; - } - - NotificationData createNotificationData(Account account, int unreadMessageCount) { - NotificationData notificationData = new NotificationData(account); - notificationData.setUnreadMessageCount(unreadMessageCount); - return notificationData; - } - - private void cancelNotification(int notificationId) { - getNotificationManager().cancel(notificationId); - } - - private void updateSummaryNotification(Account account, NotificationData notificationData) { - if (notificationData.getNewMessagesCount() == 0) { - clearNewMailNotifications(account); - } else { - createSummaryNotification(account, notificationData, true); - } - } - - private void createSummaryNotification(Account account, NotificationData notificationData, boolean silent) { - Notification notification = deviceNotifications.buildSummaryNotification(account, notificationData, silent); - int notificationId = NotificationIds.getNewMailSummaryNotificationId(account); - - getNotificationManager().notify(notificationId, notification); - } - - private void createStackedNotification(Account account, NotificationHolder holder) { - if (isPrivacyModeEnabled()) { - return; - } - - Notification notification = wearNotifications.buildStackedNotification(account, holder); - int notificationId = holder.notificationId; - - getNotificationManager().notify(notificationId, notification); - } - - private boolean isPrivacyModeEnabled() { - return K9.getNotificationHideSubject() != NotificationHideSubject.NEVER; - } - - private NotificationManagerCompat getNotificationManager() { - return notificationHelper.getNotificationManager(); - } -} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationActionCreator.java b/app/core/src/main/java/com/fsck/k9/notification/NotificationActionCreator.java deleted file mode 100644 index 94e781c4d8f835841d339d576ea274e3a8625c63..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/notification/NotificationActionCreator.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.fsck.k9.notification; - - -import java.util.List; - -import android.app.PendingIntent; -import android.content.Context; - -import com.fsck.k9.Account; -import com.fsck.k9.controller.MessageReference; - - -public interface NotificationActionCreator { - PendingIntent createViewMessagePendingIntent(MessageReference messageReference, int notificationId); - - PendingIntent createViewFolderPendingIntent(Account account, long folderId, int notificationId); - - PendingIntent createViewMessagesPendingIntent(Account account, List messageReferences, - int notificationId); - - PendingIntent createViewFolderListPendingIntent(Account account, int notificationId); - - PendingIntent createDismissAllMessagesPendingIntent(Account account, int notificationId); - - PendingIntent createDismissMessagePendingIntent(Context context, MessageReference messageReference, - int notificationId); - - PendingIntent createReplyPendingIntent(MessageReference messageReference, int notificationId); - - PendingIntent createMarkMessageAsReadPendingIntent(MessageReference messageReference, int notificationId); - - PendingIntent createMarkAllAsReadPendingIntent(Account account, List messageReferences, - int notificationId); - - PendingIntent getEditIncomingServerSettingsIntent(Account account); - - PendingIntent getEditOutgoingServerSettingsIntent(Account account); - - PendingIntent createDeleteMessagePendingIntent(MessageReference messageReference, int notificationId); - - PendingIntent createDeleteAllPendingIntent(Account account, List messageReferences, - int notificationId); - - PendingIntent createArchiveMessagePendingIntent(MessageReference messageReference, int notificationId); - - PendingIntent createArchiveAllPendingIntent(Account account, List messageReferences, - int notificationId); - - PendingIntent createMarkMessageAsSpamPendingIntent(MessageReference messageReference, int notificationId); -} 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 new file mode 100644 index 0000000000000000000000000000000000000000..d7e403b8c315bbd1d65326ab97bddfd161bbc6d0 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationActionCreator.kt @@ -0,0 +1,58 @@ +package com.fsck.k9.notification + +import android.app.PendingIntent +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( + messageReference: MessageReference, + notificationId: Int + ): PendingIntent + + fun createReplyPendingIntent(messageReference: MessageReference, notificationId: Int): PendingIntent + + fun createMarkMessageAsReadPendingIntent(messageReference: MessageReference, notificationId: Int): PendingIntent + + fun createMarkAllAsReadPendingIntent( + account: Account, + messageReferences: List, + notificationId: Int + ): PendingIntent + + fun getEditIncomingServerSettingsIntent(account: Account): PendingIntent + + fun getEditOutgoingServerSettingsIntent(account: Account): PendingIntent + + fun createDeleteMessagePendingIntent(messageReference: MessageReference, notificationId: Int): PendingIntent + + fun createDeleteAllPendingIntent( + account: Account, + messageReferences: List, + notificationId: Int + ): PendingIntent + + fun createArchiveMessagePendingIntent(messageReference: MessageReference, notificationId: Int): PendingIntent + + fun createArchiveAllPendingIntent( + account: Account, + messageReferences: List, + notificationId: Int + ): PendingIntent + + fun createMarkMessageAsSpamPendingIntent(messageReference: MessageReference, notificationId: Int): PendingIntent +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationActionService.java b/app/core/src/main/java/com/fsck/k9/notification/NotificationActionService.java deleted file mode 100644 index a0453579e60ed0420fed713c77eed9ece3546e9e..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/notification/NotificationActionService.java +++ /dev/null @@ -1,247 +0,0 @@ -package com.fsck.k9.notification; - - -import java.util.ArrayList; -import java.util.List; - -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.os.IBinder; - -import androidx.annotation.Nullable; - -import timber.log.Timber; - -import com.fsck.k9.Account; -import com.fsck.k9.K9; -import com.fsck.k9.Preferences; -import com.fsck.k9.controller.MessageReference; -import com.fsck.k9.controller.MessagingController; -import com.fsck.k9.mail.Flag; - -import static com.fsck.k9.controller.MessageReferenceHelper.toMessageReferenceList; -import static com.fsck.k9.controller.MessageReferenceHelper.toMessageReferenceStringList; - - -public class NotificationActionService extends Service { - private static final String ACTION_MARK_AS_READ = "ACTION_MARK_AS_READ"; - private static final String ACTION_DELETE = "ACTION_DELETE"; - private static final String ACTION_ARCHIVE = "ACTION_ARCHIVE"; - private static final String ACTION_SPAM = "ACTION_SPAM"; - private static final String ACTION_DISMISS = "ACTION_DISMISS"; - - private static final String EXTRA_ACCOUNT_UUID = "accountUuid"; - private static final String EXTRA_MESSAGE_REFERENCE = "messageReference"; - private static final String EXTRA_MESSAGE_REFERENCES = "messageReferences"; - - - public static Intent createMarkMessageAsReadIntent(Context context, MessageReference messageReference) { - Intent intent = new Intent(context, NotificationActionService.class); - intent.setAction(ACTION_MARK_AS_READ); - intent.putExtra(EXTRA_ACCOUNT_UUID, messageReference.getAccountUuid()); - intent.putExtra(EXTRA_MESSAGE_REFERENCES, createSingleItemArrayList(messageReference)); - - return intent; - } - - public static Intent createMarkAllAsReadIntent(Context context, String accountUuid, - List messageReferences) { - Intent intent = new Intent(context, NotificationActionService.class); - intent.setAction(ACTION_MARK_AS_READ); - intent.putExtra(EXTRA_ACCOUNT_UUID, accountUuid); - intent.putExtra(EXTRA_MESSAGE_REFERENCES, toMessageReferenceStringList(messageReferences)); - - return intent; - } - - public static Intent createDismissMessageIntent(Context context, MessageReference messageReference) { - Intent intent = new Intent(context, NotificationActionService.class); - intent.setAction(ACTION_DISMISS); - intent.putExtra(EXTRA_ACCOUNT_UUID, messageReference.getAccountUuid()); - intent.putExtra(EXTRA_MESSAGE_REFERENCE, messageReference.toIdentityString()); - - return intent; - } - - public static Intent createDismissAllMessagesIntent(Context context, Account account) { - Intent intent = new Intent(context, NotificationActionService.class); - intent.setAction(ACTION_DISMISS); - intent.putExtra(EXTRA_ACCOUNT_UUID, account.getUuid()); - - return intent; - } - - public static Intent createDeleteMessageIntent(Context context, MessageReference messageReference) { - Intent intent = new Intent(context, NotificationActionService.class); - intent.setAction(ACTION_DELETE); - intent.putExtra(EXTRA_ACCOUNT_UUID, messageReference.getAccountUuid()); - intent.putExtra(EXTRA_MESSAGE_REFERENCES, createSingleItemArrayList(messageReference)); - - return intent; - } - - public static Intent createDeleteAllMessagesIntent(Context context, String accountUuid, - List messageReferences) { - Intent intent = new Intent(context, NotificationActionService.class); - intent.setAction(ACTION_DELETE); - intent.putExtra(EXTRA_ACCOUNT_UUID, accountUuid); - intent.putExtra(EXTRA_MESSAGE_REFERENCES, toMessageReferenceStringList(messageReferences)); - - return intent; - } - - public static Intent createArchiveMessageIntent(Context context, MessageReference messageReference) { - Intent intent = new Intent(context, NotificationActionService.class); - intent.setAction(ACTION_ARCHIVE); - intent.putExtra(EXTRA_ACCOUNT_UUID, messageReference.getAccountUuid()); - intent.putExtra(EXTRA_MESSAGE_REFERENCES, createSingleItemArrayList(messageReference)); - - return intent; - } - - public static Intent createArchiveAllIntent(Context context, Account account, - List messageReferences) { - Intent intent = new Intent(context, NotificationActionService.class); - intent.setAction(ACTION_ARCHIVE); - intent.putExtra(EXTRA_ACCOUNT_UUID, account.getUuid()); - intent.putExtra(EXTRA_MESSAGE_REFERENCES, toMessageReferenceStringList(messageReferences)); - - return intent; - } - - public static Intent createMarkMessageAsSpamIntent(Context context, MessageReference messageReference) { - Intent intent = new Intent(context, NotificationActionService.class); - intent.setAction(ACTION_SPAM); - intent.putExtra(EXTRA_ACCOUNT_UUID, messageReference.getAccountUuid()); - intent.putExtra(EXTRA_MESSAGE_REFERENCE, messageReference.toIdentityString()); - - return intent; - } - - private static ArrayList createSingleItemArrayList(MessageReference messageReference) { - ArrayList messageReferenceStrings = new ArrayList<>(1); - messageReferenceStrings.add(messageReference.toIdentityString()); - return messageReferenceStrings; - } - - @Override - public final int onStartCommand(Intent intent, int flags, int startId) { - Timber.i("NotificationActionService started with startId = %d", startId); - - String accountUuid = intent.getStringExtra(EXTRA_ACCOUNT_UUID); - Preferences preferences = Preferences.getPreferences(this); - Account account = preferences.getAccount(accountUuid); - - if (account == null) { - Timber.w("Could not find account for notification action."); - return START_NOT_STICKY; - } - - MessagingController controller = MessagingController.getInstance(getApplication()); - - String action = intent.getAction(); - if (ACTION_MARK_AS_READ.equals(action)) { - markMessagesAsRead(intent, account, controller); - } else if (ACTION_DELETE.equals(action)) { - deleteMessages(intent, controller); - } else if (ACTION_ARCHIVE.equals(action)) { - archiveMessages(intent, account, controller); - } else if (ACTION_SPAM.equals(action)) { - markMessageAsSpam(intent, account, controller); - } else if (ACTION_DISMISS.equals(action)) { - Timber.i("Notification dismissed"); - } - - cancelNotifications(intent, account, controller); - - return START_NOT_STICKY; - } - - @Nullable - @Override - public IBinder onBind(Intent intent) { - return null; - } - - private void markMessagesAsRead(Intent intent, Account account, MessagingController controller) { - Timber.i("NotificationActionService marking messages as read"); - - List messageReferenceStrings = intent.getStringArrayListExtra(EXTRA_MESSAGE_REFERENCES); - List messageReferences = toMessageReferenceList(messageReferenceStrings); - for (MessageReference messageReference : messageReferences) { - long folderId = messageReference.getFolderId(); - String uid = messageReference.getUid(); - controller.setFlag(account, folderId, uid, Flag.SEEN, true); - } - } - - private void deleteMessages(Intent intent, MessagingController controller) { - Timber.i("NotificationActionService deleting messages"); - - List messageReferenceStrings = intent.getStringArrayListExtra(EXTRA_MESSAGE_REFERENCES); - List messageReferences = toMessageReferenceList(messageReferenceStrings); - controller.deleteMessages(messageReferences); - } - - private void archiveMessages(Intent intent, Account account, MessagingController controller) { - Timber.i("NotificationActionService archiving messages"); - - Long archiveFolderId = account.getArchiveFolderId(); - if (!isMovePossible(controller, account, archiveFolderId)) { - Timber.w("Can not archive messages"); - return; - } - - List messageReferenceStrings = intent.getStringArrayListExtra(EXTRA_MESSAGE_REFERENCES); - List messageReferences = toMessageReferenceList(messageReferenceStrings); - for (MessageReference messageReference : messageReferences) { - if (controller.isMoveCapable(messageReference)) { - long sourceFolderId = messageReference.getFolderId(); - controller.moveMessage(account, sourceFolderId, messageReference, archiveFolderId); - } - } - } - - private void markMessageAsSpam(Intent intent, Account account, MessagingController controller) { - Timber.i("NotificationActionService moving messages to spam"); - - String messageReferenceString = intent.getStringExtra(EXTRA_MESSAGE_REFERENCE); - MessageReference messageReference = MessageReference.parse(messageReferenceString); - if (messageReference == null) { - Timber.w("Invalid message reference: %s", messageReferenceString); - return; - } - - Long spamFolderId = account.getSpamFolderId(); - if (!K9.isConfirmSpam() && isMovePossible(controller, account, spamFolderId)) { - long sourceFolderId = messageReference.getFolderId(); - controller.moveMessage(account, sourceFolderId, messageReference, spamFolderId); - } - } - - private void cancelNotifications(Intent intent, Account account, MessagingController controller) { - if (intent.hasExtra(EXTRA_MESSAGE_REFERENCE)) { - String messageReferenceString = intent.getStringExtra(EXTRA_MESSAGE_REFERENCE); - MessageReference messageReference = MessageReference.parse(messageReferenceString); - if (messageReference != null) { - controller.cancelNotificationForMessage(account, messageReference); - } else { - Timber.w("Invalid message reference: %s", messageReferenceString); - } - } else if (intent.hasExtra(EXTRA_MESSAGE_REFERENCES)) { - List messageReferenceStrings = intent.getStringArrayListExtra(EXTRA_MESSAGE_REFERENCES); - List messageReferences = toMessageReferenceList(messageReferenceStrings); - for (MessageReference messageReference : messageReferences) { - controller.cancelNotificationForMessage(account, messageReference); - } - } else { - controller.cancelNotificationsForAccount(account); - } - } - - private boolean isMovePossible(MessagingController controller, Account account, Long destinationFolderId) { - boolean isSpecialFolderConfigured = destinationFolderId != null; - return isSpecialFolderConfigured && controller.isMoveCapable(account); - } -} 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 new file mode 100644 index 0000000000000000000000000000000000000000..d70ea87778326e3c891c99868bf21dc387dd459c --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationActionService.kt @@ -0,0 +1,244 @@ +package com.fsck.k9.notification + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import com.fsck.k9.Account +import com.fsck.k9.K9 +import com.fsck.k9.Preferences +import com.fsck.k9.controller.MessageReference +import com.fsck.k9.controller.MessageReferenceHelper +import com.fsck.k9.controller.MessagingController +import com.fsck.k9.mail.Flag +import org.koin.android.ext.android.inject +import timber.log.Timber + +class NotificationActionService : Service() { + private val preferences: Preferences by inject() + private val messagingController: MessagingController by inject() + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + Timber.i("NotificationActionService started with startId = %d", startId) + + val accountUuid = intent.getStringExtra(EXTRA_ACCOUNT_UUID) ?: error("Missing account UUID") + + val account = preferences.getAccount(accountUuid) + if (account == null) { + Timber.w("Could not find account for notification action.") + return START_NOT_STICKY + } + + when (intent.action) { + ACTION_MARK_AS_READ -> markMessagesAsRead(intent, account) + ACTION_DELETE -> deleteMessages(intent) + ACTION_ARCHIVE -> archiveMessages(intent, account) + ACTION_SPAM -> markMessageAsSpam(intent, account) + ACTION_DISMISS -> Timber.i("Notification dismissed") + } + + cancelNotifications(intent, account) + + return START_NOT_STICKY + } + + override fun onBind(intent: Intent): IBinder? { + return null + } + + 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 + messagingController.setFlag(account, folderId, uid, Flag.SEEN, true) + } + } + + 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) + + for (messageReference in messageReferences) { + if (messagingController.isMoveCapable(messageReference)) { + val sourceFolderId = messageReference.folderId + messagingController.moveMessage(account, sourceFolderId, messageReference, archiveFolderId) + } + } + } + + 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) + + if (messageReference == null) { + Timber.w("Invalid message reference: %s", messageReferenceString) + return + } + + val spamFolderId = account.spamFolderId + if (spamFolderId == null) { + Timber.w("No spam folder configured") + return + } + + if (!K9.isConfirmSpam && messagingController.isMoveCapable(account)) { + val sourceFolderId = messageReference.folderId + messagingController.moveMessage(account, sourceFolderId, messageReference, spamFolderId) + } + } + + private fun cancelNotifications(intent: Intent, account: Account) { + 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 { + Timber.w("Invalid message reference: %s", messageReferenceString) + } + } 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) + } + } else { + messagingController.cancelNotificationsForAccount(account) + } + } + + companion object { + private const val ACTION_MARK_AS_READ = "ACTION_MARK_AS_READ" + private const val ACTION_DELETE = "ACTION_DELETE" + private const val ACTION_ARCHIVE = "ACTION_ARCHIVE" + private const val ACTION_SPAM = "ACTION_SPAM" + private const val ACTION_DISMISS = "ACTION_DISMISS" + private const val EXTRA_ACCOUNT_UUID = "accountUuid" + private const val EXTRA_MESSAGE_REFERENCE = "messageReference" + private const val EXTRA_MESSAGE_REFERENCES = "messageReferences" + + fun createMarkMessageAsReadIntent(context: Context, messageReference: MessageReference): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_MARK_AS_READ + putExtra(EXTRA_ACCOUNT_UUID, messageReference.accountUuid) + putExtra(EXTRA_MESSAGE_REFERENCES, createSingleItemArrayList(messageReference)) + } + } + + fun createMarkAllAsReadIntent( + context: Context, + accountUuid: String, + messageReferences: List + ): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_MARK_AS_READ + putExtra(EXTRA_ACCOUNT_UUID, accountUuid) + putExtra( + EXTRA_MESSAGE_REFERENCES, + MessageReferenceHelper.toMessageReferenceStringList(messageReferences) + ) + } + } + + fun createDismissMessageIntent(context: Context, messageReference: MessageReference): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_DISMISS + putExtra(EXTRA_ACCOUNT_UUID, messageReference.accountUuid) + putExtra(EXTRA_MESSAGE_REFERENCE, messageReference.toIdentityString()) + } + } + + fun createDismissAllMessagesIntent(context: Context, account: Account): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_DISMISS + putExtra(EXTRA_ACCOUNT_UUID, account.uuid) + } + } + + fun createDeleteMessageIntent(context: Context, messageReference: MessageReference): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_DELETE + putExtra(EXTRA_ACCOUNT_UUID, messageReference.accountUuid) + putExtra(EXTRA_MESSAGE_REFERENCES, createSingleItemArrayList(messageReference)) + } + } + + fun createDeleteAllMessagesIntent( + context: Context, + accountUuid: String, + messageReferences: List + ): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_DELETE + putExtra(EXTRA_ACCOUNT_UUID, accountUuid) + putExtra( + EXTRA_MESSAGE_REFERENCES, + MessageReferenceHelper.toMessageReferenceStringList(messageReferences) + ) + } + } + + fun createArchiveMessageIntent(context: Context, messageReference: MessageReference): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_ARCHIVE + putExtra(EXTRA_ACCOUNT_UUID, messageReference.accountUuid) + putExtra(EXTRA_MESSAGE_REFERENCES, createSingleItemArrayList(messageReference)) + } + } + + fun createArchiveAllIntent( + context: Context, + account: Account, + messageReferences: List + ): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_ARCHIVE + putExtra(EXTRA_ACCOUNT_UUID, account.uuid) + putExtra( + EXTRA_MESSAGE_REFERENCES, + MessageReferenceHelper.toMessageReferenceStringList(messageReferences) + ) + } + } + + fun createMarkMessageAsSpamIntent(context: Context, messageReference: MessageReference): Intent { + return Intent(context, NotificationActionService::class.java).apply { + action = ACTION_SPAM + putExtra(EXTRA_ACCOUNT_UUID, messageReference.accountUuid) + putExtra(EXTRA_MESSAGE_REFERENCE, messageReference.toIdentityString()) + } + } + + private fun createSingleItemArrayList(messageReference: MessageReference): ArrayList { + return ArrayList(1).apply { + add(messageReference.toIdentityString()) + } + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationChannelManager.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationChannelManager.kt index 71c2e728e980ddc40ecc921fd26ad10aabeca6fb..9597b082d5cb3e3d387e6b6d5748fee11838ea5a 100644 --- a/app/core/src/main/java/com/fsck/k9/notification/NotificationChannelManager.kt +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationChannelManager.kt @@ -6,8 +6,10 @@ import android.app.NotificationManager import android.os.Build import androidx.annotation.RequiresApi import com.fsck.k9.Account +import com.fsck.k9.NotificationSetting import com.fsck.k9.Preferences import java.util.concurrent.Executor +import timber.log.Timber class NotificationChannelManager( private val preferences: Preferences, @@ -137,12 +139,91 @@ class NotificationChannelManager( } fun getChannelIdFor(account: Account, channelType: ChannelType): String { - val accountUuid = account.uuid - return if (channelType == ChannelType.MESSAGES) { - "messages_channel_$accountUuid" + "messages_channel_${account.uuid}${account.messagesNotificationChannelSuffix}" } else { - "miscellaneous_channel_$accountUuid" + "miscellaneous_channel_${account.uuid}" + } + } + + @RequiresApi(Build.VERSION_CODES.O) + fun getNotificationConfiguration(account: Account): NotificationConfiguration { + val channelId = getChannelIdFor(account, ChannelType.MESSAGES) + val notificationChannel = notificationManager.getNotificationChannel(channelId) + + return NotificationConfiguration( + isBlinkLightsEnabled = notificationChannel.shouldShowLights(), + lightColor = notificationChannel.lightColor, + isVibrationEnabled = notificationChannel.shouldVibrate(), + vibrationPattern = notificationChannel.vibrationPattern?.toList() + ) + } + + fun recreateMessagesNotificationChannel(account: Account) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + + val oldChannelId = getChannelIdFor(account, ChannelType.MESSAGES) + val oldNotificationChannel = notificationManager.getNotificationChannel(oldChannelId) + + if (oldNotificationChannel.matches(account.notificationSetting)) { + Timber.v("Not recreating NotificationChannel. The current one already matches the app's settings.") + return + } + + notificationManager.deleteNotificationChannel(oldChannelId) + + account.incrementMessagesNotificationChannelVersion() + + val newChannelId = getChannelIdFor(account, ChannelType.MESSAGES) + val channelName = resourceProvider.messagesChannelName + val importance = oldNotificationChannel.importance + + val newNotificationChannel = NotificationChannel(newChannelId, channelName, importance).apply { + description = resourceProvider.messagesChannelDescription + group = account.uuid + + copyPropertiesFrom(oldNotificationChannel) + copyPropertiesFrom(account.notificationSetting) + } + + Timber.v("Recreating NotificationChannel(%s => %s)", oldChannelId, newChannelId) + notificationManager.createNotificationChannel(newNotificationChannel) + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun NotificationChannel.matches(notificationSetting: NotificationSetting): Boolean { + return lightColor == notificationSetting.ledColor && + vibrationPattern.contentEquals(notificationSetting.vibration) + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun NotificationChannel.copyPropertiesFrom(otherNotificationChannel: NotificationChannel) { + setShowBadge(otherNotificationChannel.canShowBadge()) + setSound(otherNotificationChannel.sound, otherNotificationChannel.audioAttributes) + enableVibration(otherNotificationChannel.shouldVibrate()) + enableLights(otherNotificationChannel.shouldShowLights()) + setBypassDnd(otherNotificationChannel.canBypassDnd()) + lockscreenVisibility = otherNotificationChannel.lockscreenVisibility + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + setAllowBubbles(otherNotificationChannel.canBubble()) } } + + @RequiresApi(Build.VERSION_CODES.O) + private fun NotificationChannel.copyPropertiesFrom(notificationSetting: NotificationSetting) { + lightColor = notificationSetting.ledColor + if (shouldVibrate()) { + vibrationPattern = notificationSetting.vibration + } + } + + private val Account.messagesNotificationChannelSuffix: String + get() = messagesNotificationChannelVersion.let { version -> if (version == 0) "" else "_$version" } } + +data class NotificationConfiguration( + val isBlinkLightsEnabled: Boolean, + val lightColor: Int, + val isVibrationEnabled: Boolean, + val vibrationPattern: List? +) diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationContent.java b/app/core/src/main/java/com/fsck/k9/notification/NotificationContent.java deleted file mode 100644 index f3dc8cca4813e298ad9dfb3c740e3de214e96325..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/notification/NotificationContent.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.fsck.k9.notification; - - -import com.fsck.k9.controller.MessageReference; - - -class NotificationContent { - public final MessageReference messageReference; - public final String sender; - public final String subject; - public final CharSequence preview; - public final CharSequence summary; - public final boolean starred; - - - public NotificationContent(MessageReference messageReference, String sender, String subject, CharSequence preview, - CharSequence summary, boolean starred) { - this.messageReference = messageReference; - this.sender = sender; - this.subject = subject; - this.preview = preview; - this.summary = summary; - this.starred = starred; - } -} 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 new file mode 100644 index 0000000000000000000000000000000000000000..374979d2e101a7d45b7a878d7d0244b1646bf318 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationContent.kt @@ -0,0 +1,11 @@ +package com.fsck.k9.notification + +import com.fsck.k9.controller.MessageReference + +internal data class NotificationContent( + val messageReference: MessageReference, + val sender: String, + val subject: String, + val preview: CharSequence, + val summary: CharSequence +) diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationContentCreator.java b/app/core/src/main/java/com/fsck/k9/notification/NotificationContentCreator.java deleted file mode 100644 index 6d98f508f3533c23831805fe400758ef7b37baeb..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/notification/NotificationContentCreator.java +++ /dev/null @@ -1,129 +0,0 @@ -package com.fsck.k9.notification; - - -import android.content.Context; -import android.text.SpannableStringBuilder; -import android.text.TextUtils; - -import com.fsck.k9.Account; -import com.fsck.k9.K9; -import com.fsck.k9.controller.MessageReference; -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.Message; -import com.fsck.k9.mailstore.LocalMessage; -import com.fsck.k9.message.extractors.PreviewResult.PreviewType; - - -class NotificationContentCreator { - private final Context context; - private final NotificationResourceProvider resourceProvider; - - - public NotificationContentCreator(Context context, NotificationResourceProvider resourceProvider) { - this.context = context; - this.resourceProvider = resourceProvider; - } - - public NotificationContent createFromMessage(Account account, LocalMessage message) { - MessageReference messageReference = message.makeMessageReference(); - String sender = getMessageSender(account, message); - String displaySender = getMessageSenderForDisplay(sender); - String subject = getMessageSubject(message); - CharSequence preview = getMessagePreview(message); - CharSequence summary = buildMessageSummary(sender, subject); - boolean starred = message.isSet(Flag.FLAGGED); - - return new NotificationContent(messageReference, displaySender, subject, preview, summary, starred); - } - - private CharSequence getMessagePreview(LocalMessage message) { - String subject = message.getSubject(); - String snippet = getPreview(message); - - boolean isSubjectEmpty = TextUtils.isEmpty(subject); - boolean isSnippetPresent = snippet != null; - if (isSubjectEmpty && isSnippetPresent) { - return snippet; - } - - String displaySubject = getMessageSubject(message); - - SpannableStringBuilder preview = new SpannableStringBuilder(); - preview.append(displaySubject); - if (isSnippetPresent) { - preview.append('\n'); - preview.append(snippet); - } - - return preview; - } - - private String getPreview(LocalMessage message) { - PreviewType previewType = message.getPreviewType(); - switch (previewType) { - case NONE: - case ERROR: - return null; - case TEXT: - return message.getPreview(); - case ENCRYPTED: - return resourceProvider.previewEncrypted(); - } - - throw new AssertionError("Unknown preview type: " + previewType); - } - - private CharSequence buildMessageSummary(String sender, String subject) { - if (sender == null) { - return subject; - } - - SpannableStringBuilder summary = new SpannableStringBuilder(); - summary.append(sender); - summary.append(" "); - summary.append(subject); - - return summary; - } - - private String getMessageSubject(Message message) { - String subject = message.getSubject(); - if (!TextUtils.isEmpty(subject)) { - return subject; - } - - return resourceProvider.noSubject(); - } - - private String getMessageSender(Account account, Message message) { - boolean isSelf = false; - final Contacts contacts = K9.isShowContactName() ? Contacts.getInstance(context) : null; - final Address[] fromAddresses = message.getFrom(); - - if (fromAddresses != null) { - isSelf = account.isAnIdentity(fromAddresses); - if (!isSelf && fromAddresses.length > 0) { - return MessageHelper.toFriendly(fromAddresses[0], contacts).toString(); - } - } - - if (isSelf) { - // show To: if the message was sent from me - Address[] recipients = message.getRecipients(Message.RecipientType.TO); - - if (recipients != null && recipients.length > 0) { - String recipientDisplayName = MessageHelper.toFriendly(recipients[0], contacts).toString(); - return resourceProvider.recipientDisplayName(recipientDisplayName); - } - } - - return null; - } - - private String getMessageSenderForDisplay(String sender) { - return (sender != null) ? sender : resourceProvider.noSender(); - } -} 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 new file mode 100644 index 0000000000000000000000000000000000000000..86d85b8efa8e3e81e06f079752228d61a40d2286 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationContentCreator.kt @@ -0,0 +1,99 @@ +package com.fsck.k9.notification + +import android.content.Context +import android.text.SpannableStringBuilder +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.Message +import com.fsck.k9.mailstore.LocalMessage +import com.fsck.k9.message.extractors.PreviewResult.PreviewType + +internal class NotificationContentCreator( + private val context: Context, + private val resourceProvider: NotificationResourceProvider +) { + fun createFromMessage(account: Account, message: LocalMessage): NotificationContent { + val sender = getMessageSender(account, message) + + return NotificationContent( + messageReference = message.makeMessageReference(), + sender = getMessageSenderForDisplay(sender), + subject = getMessageSubject(message), + preview = getMessagePreview(message), + summary = buildMessageSummary(sender, getMessageSubject(message)) + ) + } + + private fun getMessagePreview(message: LocalMessage): CharSequence { + val snippet = getPreview(message) + if (message.subject.isNullOrEmpty() && snippet != null) { + return snippet + } + + return SpannableStringBuilder().apply { + val displaySubject = getMessageSubject(message) + append(displaySubject) + + if (snippet != null) { + append('\n') + append(snippet) + } + } + } + + private fun getPreview(message: LocalMessage): String? { + val previewType = message.previewType ?: error("previewType == null") + return when (previewType) { + PreviewType.NONE, PreviewType.ERROR -> null + PreviewType.TEXT -> message.preview + PreviewType.ENCRYPTED -> resourceProvider.previewEncrypted() + } + } + + private fun buildMessageSummary(sender: String?, subject: String): CharSequence { + return if (sender == null) { + subject + } else { + SpannableStringBuilder().apply { + append(sender) + append(" ") + append(subject) + } + } + } + + private fun getMessageSubject(message: Message): String { + val subject = message.subject.orEmpty() + return subject.ifEmpty { resourceProvider.noSubject() } + } + + 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) + if (!isSelf) { + return MessageHelper.toFriendly(fromAddresses.first(), contacts).toString() + } + } + + if (isSelf) { + // show To: if the message was sent from me + val recipients = message.getRecipients(Message.RecipientType.TO) + if (!recipients.isNullOrEmpty()) { + val recipientDisplayName = MessageHelper.toFriendly(recipients.first(), contacts).toString() + return resourceProvider.recipientDisplayName(recipientDisplayName) + } + } + + return null + } + + private fun getMessageSenderForDisplay(sender: String?): String { + return sender ?: resourceProvider.noSender() + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationController.java b/app/core/src/main/java/com/fsck/k9/notification/NotificationController.java deleted file mode 100644 index 1426f3e6f5953ee9ecba0d57ecc9a3fb8dc60da1..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/notification/NotificationController.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.fsck.k9.notification; - - -import com.fsck.k9.Account; -import com.fsck.k9.controller.MessageReference; -import com.fsck.k9.mailstore.LocalFolder; -import com.fsck.k9.mailstore.LocalMessage; - - -public class NotificationController { - private final CertificateErrorNotifications certificateErrorNotifications; - private final AuthenticationErrorNotifications authenticationErrorNotifications; - private final SyncNotifications syncNotifications; - private final SendFailedNotifications sendFailedNotifications; - private final NewMailNotifications newMailNotifications; - - - NotificationController( - CertificateErrorNotifications certificateErrorNotifications, - AuthenticationErrorNotifications authenticationErrorNotifications, - SyncNotifications syncNotifications, - SendFailedNotifications sendFailedNotifications, - NewMailNotifications newMailNotifications - ) { - this.certificateErrorNotifications = certificateErrorNotifications; - this.authenticationErrorNotifications = authenticationErrorNotifications; - this.syncNotifications = syncNotifications; - this.sendFailedNotifications = sendFailedNotifications; - this.newMailNotifications = newMailNotifications; - } - - public void showCertificateErrorNotification(Account account, boolean incoming) { - certificateErrorNotifications.showCertificateErrorNotification(account, incoming); - } - - public void clearCertificateErrorNotifications(Account account, boolean incoming) { - certificateErrorNotifications.clearCertificateErrorNotifications(account, incoming); - } - - public void showAuthenticationErrorNotification(Account account, boolean incoming) { - authenticationErrorNotifications.showAuthenticationErrorNotification(account, incoming); - } - - public void clearAuthenticationErrorNotification(Account account, boolean incoming) { - authenticationErrorNotifications.clearAuthenticationErrorNotification(account, incoming); - } - - public void showSendingNotification(Account account) { - syncNotifications.showSendingNotification(account); - } - - public void clearSendingNotification(Account account) { - syncNotifications.clearSendingNotification(account); - } - - public void showSendFailedNotification(Account account, Exception exception) { - sendFailedNotifications.showSendFailedNotification(account, exception); - } - - public void clearSendFailedNotification(Account account) { - sendFailedNotifications.clearSendFailedNotification(account); - } - - public void showFetchingMailNotification(Account account, LocalFolder folder) { - syncNotifications.showFetchingMailNotification(account, folder); - } - - public void clearFetchingMailNotification(Account account) { - syncNotifications.clearFetchingMailNotification(account); - } - - public void addNewMailNotification(Account account, LocalMessage message, int previousUnreadMessageCount) { - newMailNotifications.addNewMailNotification(account, message, previousUnreadMessageCount); - } - - public void removeNewMailNotification(Account account, MessageReference messageReference) { - newMailNotifications.removeNewMailNotification(account, messageReference); - } - - public void clearNewMailNotifications(Account account) { - newMailNotifications.clearNewMailNotifications(account); - } -} 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 new file mode 100644 index 0000000000000000000000000000000000000000..fe0144108e8d4c800d930123bb743c28811318d4 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationController.kt @@ -0,0 +1,80 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.controller.MessageReference +import com.fsck.k9.mailstore.LocalFolder +import com.fsck.k9.mailstore.LocalMessage + +class NotificationController internal constructor( + 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) { + certificateErrorNotificationController.showCertificateErrorNotification(account, incoming) + } + + fun clearCertificateErrorNotifications(account: Account, incoming: Boolean) { + certificateErrorNotificationController.clearCertificateErrorNotifications(account, incoming) + } + + fun showAuthenticationErrorNotification(account: Account, incoming: Boolean) { + authenticationErrorNotificationController.showAuthenticationErrorNotification(account, incoming) + } + + fun clearAuthenticationErrorNotification(account: Account, incoming: Boolean) { + authenticationErrorNotificationController.clearAuthenticationErrorNotification(account, incoming) + } + + fun showSendingNotification(account: Account) { + syncNotificationController.showSendingNotification(account) + } + + fun clearSendingNotification(account: Account) { + syncNotificationController.clearSendingNotification(account) + } + + fun showSendFailedNotification(account: Account, exception: Exception) { + sendFailedNotificationController.showSendFailedNotification(account, exception) + } + + fun clearSendFailedNotification(account: Account) { + sendFailedNotificationController.clearSendFailedNotification(account) + } + + fun showFetchingMailNotification(account: Account, folder: LocalFolder) { + syncNotificationController.showFetchingMailNotification(account, folder) + } + + fun showEmptyFetchingMailNotification(account: Account) { + syncNotificationController.showEmptyFetchingMailNotification(account) + } + + fun clearFetchingMailNotification(account: Account) { + syncNotificationController.clearFetchingMailNotification(account) + } + + fun restoreNewMailNotifications(accounts: List) { + newMailNotificationController.restoreNewMailNotifications(accounts) + } + + fun addNewMailNotification(account: Account, message: LocalMessage, silent: Boolean) { + newMailNotificationController.addNewMailNotification(account, message, silent) + } + + fun removeNewMailNotification(account: Account, messageReference: MessageReference) { + newMailNotificationController.removeNewMailNotifications(account, clearNewMessageState = true) { + listOf(messageReference) + } + } + + fun clearNewMailNotifications(account: Account, selector: (List) -> List) { + newMailNotificationController.removeNewMailNotifications(account, clearNewMessageState = false, selector) + } + + fun clearNewMailNotifications(account: Account, clearNewMessageState: Boolean) { + newMailNotificationController.clearNewMailNotifications(account, clearNewMessageState) + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationData.java b/app/core/src/main/java/com/fsck/k9/notification/NotificationData.java deleted file mode 100644 index 69475eaa204306241f38cca2f45ec6465a691cc1..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/notification/NotificationData.java +++ /dev/null @@ -1,224 +0,0 @@ -package com.fsck.k9.notification; - - -import java.util.ArrayList; -import java.util.Deque; -import java.util.Iterator; -import java.util.LinkedList; -import java.util.List; - -import android.util.SparseBooleanArray; - -import com.fsck.k9.Account; -import com.fsck.k9.controller.MessageReference; - - -/** - * A holder class for pending new mail notifications. - */ -class NotificationData { - // Note: As of Jellybean, phone notifications show a maximum of 5 lines, while tablet notifications show 7 lines. - static final int MAX_NUMBER_OF_MESSAGES_FOR_SUMMARY_NOTIFICATION = 5; - // Note: This class assumes MAX_NUMBER_OF_STACKED_NOTIFICATIONS >= MAX_NUMBER_OF_MESSAGES_FOR_SUMMARY_NOTIFICATION - static final int MAX_NUMBER_OF_STACKED_NOTIFICATIONS = 8; - - - private final Account account; - private final LinkedList activeNotifications = new LinkedList<>(); - private final Deque additionalNotifications = new LinkedList<>(); - private final SparseBooleanArray notificationIdsInUse = new SparseBooleanArray(); - private int unreadMessageCount; - - - public NotificationData(Account account) { - this.account = account; - } - - public AddNotificationResult addNotificationContent(NotificationContent content) { - int notificationId; - boolean cancelNotificationIdBeforeReuse; - if (isMaxNumberOfActiveNotificationsReached()) { - NotificationHolder notificationHolder = activeNotifications.removeLast(); - addToAdditionalNotifications(notificationHolder); - notificationId = notificationHolder.notificationId; - cancelNotificationIdBeforeReuse = true; - } else { - notificationId = getNewNotificationId(); - cancelNotificationIdBeforeReuse = false; - } - - NotificationHolder notificationHolder = createNotificationHolder(notificationId, content); - activeNotifications.addFirst(notificationHolder); - - if (cancelNotificationIdBeforeReuse) { - return AddNotificationResult.replaceNotification(notificationHolder); - } else { - return AddNotificationResult.newNotification(notificationHolder); - } - } - - private boolean isMaxNumberOfActiveNotificationsReached() { - return activeNotifications.size() == MAX_NUMBER_OF_STACKED_NOTIFICATIONS; - } - - private void addToAdditionalNotifications(NotificationHolder notificationHolder) { - additionalNotifications.addFirst(notificationHolder.content); - } - - private int getNewNotificationId() { - for (int i = 0; i < MAX_NUMBER_OF_STACKED_NOTIFICATIONS; i++) { - int notificationId = NotificationIds.getNewMailStackedNotificationId(account, i); - if (!isNotificationInUse(notificationId)) { - markNotificationIdAsInUse(notificationId); - return notificationId; - } - } - - throw new AssertionError("getNewNotificationId() called with no free notification ID"); - } - - private boolean isNotificationInUse(int notificationId) { - return notificationIdsInUse.get(notificationId); - } - - private void markNotificationIdAsInUse(int notificationId) { - notificationIdsInUse.put(notificationId, true); - } - - private void markNotificationIdAsFree(int notificationId) { - notificationIdsInUse.delete(notificationId); - } - - NotificationHolder createNotificationHolder(int notificationId, NotificationContent content) { - return new NotificationHolder(notificationId, content); - } - - public boolean containsStarredMessages() { - for (NotificationHolder holder : activeNotifications) { - if (holder.content.starred) { - return true; - } - } - - for (NotificationContent content : additionalNotifications) { - if (content.starred) { - return true; - } - } - - return false; - } - - public boolean hasSummaryOverflowMessages() { - return activeNotifications.size() > MAX_NUMBER_OF_MESSAGES_FOR_SUMMARY_NOTIFICATION; - } - - public int getSummaryOverflowMessagesCount() { - int activeOverflowCount = activeNotifications.size() - MAX_NUMBER_OF_MESSAGES_FOR_SUMMARY_NOTIFICATION; - if (activeOverflowCount > 0) { - return activeOverflowCount + additionalNotifications.size(); - } - return additionalNotifications.size(); - } - - public int getNewMessagesCount() { - return activeNotifications.size() + additionalNotifications.size(); - } - - public boolean isSingleMessageNotification() { - return activeNotifications.size() == 1; - } - - public NotificationHolder getHolderForLatestNotification() { - return activeNotifications.getFirst(); - } - - public List getContentForSummaryNotification() { - int size = calculateNumberOfMessagesForSummaryNotification(); - List result = new ArrayList<>(size); - - Iterator iterator = activeNotifications.iterator(); - int notificationCount = 0; - while (iterator.hasNext() && notificationCount < MAX_NUMBER_OF_MESSAGES_FOR_SUMMARY_NOTIFICATION) { - NotificationHolder holder = iterator.next(); - result.add(holder.content); - notificationCount++; - } - - return result; - } - - private int calculateNumberOfMessagesForSummaryNotification() { - return Math.min(activeNotifications.size(), MAX_NUMBER_OF_MESSAGES_FOR_SUMMARY_NOTIFICATION); - } - - public int[] getActiveNotificationIds() { - int size = activeNotifications.size(); - int[] notificationIds = new int[size]; - - for (int i = 0; i < size; i++) { - NotificationHolder holder = activeNotifications.get(i); - notificationIds[i] = holder.notificationId; - } - - return notificationIds; - } - - public RemoveNotificationResult removeNotificationForMessage(MessageReference messageReference) { - NotificationHolder holder = getNotificationHolderForMessage(messageReference); - if (holder == null) { - return RemoveNotificationResult.unknownNotification(); - } - - activeNotifications.remove(holder); - - int notificationId = holder.notificationId; - markNotificationIdAsFree(notificationId); - - if (!additionalNotifications.isEmpty()) { - NotificationContent newContent = additionalNotifications.removeFirst(); - NotificationHolder replacement = createNotificationHolder(notificationId, newContent); - activeNotifications.addLast(replacement); - return RemoveNotificationResult.createNotification(replacement); - } - - return RemoveNotificationResult.cancelNotification(notificationId); - } - - private NotificationHolder getNotificationHolderForMessage(MessageReference messageReference) { - for (NotificationHolder holder : activeNotifications) { - if (messageReference.equals(holder.content.messageReference)) { - return holder; - } - } - - return null; - } - - public Account getAccount() { - return account; - } - - public int getUnreadMessageCount() { - return unreadMessageCount + getNewMessagesCount(); - } - - public void setUnreadMessageCount(int unreadMessageCount) { - this.unreadMessageCount = unreadMessageCount; - } - - public ArrayList getAllMessageReferences() { - int newSize = activeNotifications.size() + additionalNotifications.size(); - ArrayList messageReferences = new ArrayList<>(newSize); - - for (NotificationHolder holder : activeNotifications) { - messageReferences.add(holder.content.messageReference); - } - - for (NotificationContent content : additionalNotifications) { - messageReferences.add(content.messageReference); - } - - return messageReferences; - } -} 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 new file mode 100644 index 0000000000000000000000000000000000000000..c50985022b2d6637b5d28276eb9dc1e0d1823a02 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationData.kt @@ -0,0 +1,40 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.controller.MessageReference + +/** + * Holds information about active and inactive new message notifications of an account. + */ +internal data class NotificationData( + val account: Account, + val activeNotifications: List, + val inactiveNotifications: List +) { + val newMessagesCount: Int + get() = activeNotifications.size + inactiveNotifications.size + + val isSingleMessageNotification: Boolean + get() = activeNotifications.size == 1 + + @OptIn(ExperimentalStdlibApi::class) + val messageReferences: List + get() { + return buildList(capacity = newMessagesCount) { + for (activeNotification in activeNotifications) { + add(activeNotification.content.messageReference) + } + for (inactiveNotification in inactiveNotifications) { + add(inactiveNotification.content.messageReference) + } + } + } + + fun isEmpty() = activeNotifications.isEmpty() + + companion object { + fun create(account: Account): NotificationData { + return NotificationData(account, activeNotifications = emptyList(), inactiveNotifications = emptyList()) + } + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationDataStore.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationDataStore.kt new file mode 100644 index 0000000000000000000000000000000000000000..51372b0a939d6c83d1815b49d19aff957ce0e443 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationDataStore.kt @@ -0,0 +1,177 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.controller.MessageReference + +internal const val MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS = 8 + +/** + * Stores information about new message notifications for all accounts. + * + * We only use a limited number of system notifications per account (see [MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS]); + * those are called active notifications. The rest are called inactive notifications. When an active notification is + * removed, the latest inactive notification is promoted to an active notification. + */ +internal class NotificationDataStore { + private val notificationDataMap = mutableMapOf() + + @Synchronized + fun initializeAccount( + account: Account, + activeNotifications: List, + inactiveNotifications: List + ): NotificationData { + require(activeNotifications.size <= MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS) + + return NotificationData(account, activeNotifications, inactiveNotifications).also { notificationData -> + notificationDataMap[account.uuid] = notificationData + } + } + + @Synchronized + fun addNotification(account: Account, content: NotificationContent, timestamp: Long): AddNotificationResult { + val notificationData = getNotificationData(account) + + return if (notificationData.isMaxNumberOfActiveNotificationsReached) { + val lastNotificationHolder = notificationData.activeNotifications.last() + val inactiveNotificationHolder = lastNotificationHolder.toInactiveNotificationHolder() + + val notificationId = lastNotificationHolder.notificationId + val notificationHolder = NotificationHolder(notificationId, timestamp, content) + + val operations = listOf( + NotificationStoreOperation.ChangeToInactive(lastNotificationHolder.content.messageReference), + NotificationStoreOperation.Add(content.messageReference, notificationId, timestamp) + ) + + val newNotificationData = notificationData.copy( + activeNotifications = listOf(notificationHolder) + notificationData.activeNotifications.dropLast(1), + inactiveNotifications = listOf(inactiveNotificationHolder) + notificationData.inactiveNotifications + ) + notificationDataMap[account.uuid] = newNotificationData + + AddNotificationResult.replaceNotification(newNotificationData, operations, notificationHolder) + } else { + val notificationId = notificationData.getNewNotificationId() + val notificationHolder = NotificationHolder(notificationId, timestamp, content) + + val operations = listOf( + NotificationStoreOperation.Add(content.messageReference, notificationId, timestamp) + ) + + val newNotificationData = notificationData.copy( + activeNotifications = listOf(notificationHolder) + notificationData.activeNotifications + ) + notificationDataMap[account.uuid] = newNotificationData + + AddNotificationResult.newNotification(newNotificationData, operations, notificationHolder) + } + } + + @Synchronized + fun removeNotifications( + account: Account, + selector: (List) -> List + ): RemoveNotificationsResult? { + val notificationData = getNotificationData(account) + if (notificationData.isEmpty()) return null + + val removeMessageReferences = selector.invoke(notificationData.messageReferences) + + val operations = mutableListOf() + val newNotificationHolders = mutableListOf() + val cancelNotificationIds = mutableListOf() + + for (messageReference in removeMessageReferences) { + val notificationHolder = notificationData.activeNotifications.firstOrNull { + it.content.messageReference == messageReference + } + + if (notificationHolder == null) { + val inactiveNotificationHolder = notificationData.inactiveNotifications.firstOrNull { + it.content.messageReference == messageReference + } ?: continue + + operations.add(NotificationStoreOperation.Remove(messageReference)) + + val newNotificationData = notificationData.copy( + inactiveNotifications = notificationData.inactiveNotifications - inactiveNotificationHolder + ) + notificationDataMap[account.uuid] = newNotificationData + } else if (notificationData.inactiveNotifications.isNotEmpty()) { + val newNotificationHolder = notificationData.inactiveNotifications.first() + .toNotificationHolder(notificationHolder.notificationId) + + newNotificationHolders.add(newNotificationHolder) + cancelNotificationIds.add(notificationHolder.notificationId) + + operations.add(NotificationStoreOperation.Remove(messageReference)) + operations.add( + NotificationStoreOperation.ChangeToActive( + newNotificationHolder.content.messageReference, + newNotificationHolder.notificationId + ) + ) + + val newNotificationData = notificationData.copy( + activeNotifications = notificationData.activeNotifications - notificationHolder + + newNotificationHolder, + inactiveNotifications = notificationData.inactiveNotifications.drop(1) + ) + notificationDataMap[account.uuid] = newNotificationData + } else { + cancelNotificationIds.add(notificationHolder.notificationId) + + operations.add(NotificationStoreOperation.Remove(messageReference)) + + val newNotificationData = notificationData.copy( + activeNotifications = notificationData.activeNotifications - notificationHolder + ) + notificationDataMap[account.uuid] = newNotificationData + } + } + + return if (operations.isEmpty()) { + null + } else { + RemoveNotificationsResult( + notificationData = getNotificationData(account), + notificationStoreOperations = operations, + notificationHolders = newNotificationHolders, + cancelNotificationIds = cancelNotificationIds + ) + } + } + + @Synchronized + fun clearNotifications(account: Account) { + notificationDataMap.remove(account.uuid) + } + + private fun getNotificationData(account: Account): NotificationData { + return notificationDataMap[account.uuid] ?: NotificationData.create(account).also { notificationData -> + notificationDataMap[account.uuid] = notificationData + } + } + + private val NotificationData.isMaxNumberOfActiveNotificationsReached: Boolean + get() = activeNotifications.size == MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS + + private fun NotificationData.getNewNotificationId(): Int { + val notificationIdsInUse = activeNotifications.map { it.notificationId }.toSet() + for (index in 0 until MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS) { + val notificationId = NotificationIds.getSingleMessageNotificationId(account, index) + if (notificationId !in notificationIdsInUse) { + return notificationId + } + } + + throw AssertionError("getNewNotificationId() called with no free notification ID") + } + + private fun NotificationHolder.toInactiveNotificationHolder() = InactiveNotificationHolder(timestamp, content) + + private fun InactiveNotificationHolder.toNotificationHolder(notificationId: Int): NotificationHolder { + return NotificationHolder(notificationId, timestamp, content) + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationGroupKeys.java b/app/core/src/main/java/com/fsck/k9/notification/NotificationGroupKeys.java deleted file mode 100644 index 7d95f50feba1d58247470108c2691ae3afccf2e5..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/notification/NotificationGroupKeys.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.fsck.k9.notification; - - -import com.fsck.k9.Account; - - -public class NotificationGroupKeys { - private static final String NOTIFICATION_GROUP_KEY_PREFIX = "newMailNotifications-"; - - - public static String getGroupKey(Account account) { - return NOTIFICATION_GROUP_KEY_PREFIX + account.getAccountNumber(); - } -} 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 new file mode 100644 index 0000000000000000000000000000000000000000..1f53762bbc49bfd350541524127cab7e78aa3f62 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationGroupKeys.kt @@ -0,0 +1,11 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account + +object NotificationGroupKeys { + private const val NOTIFICATION_GROUP_KEY_PREFIX = "newMailNotifications-" + + fun getGroupKey(account: Account): String { + return NOTIFICATION_GROUP_KEY_PREFIX + account.accountNumber + } +} 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 606edc184e65006599dc6703c46eaa0a6454148d..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 @@ -23,6 +23,7 @@ class NotificationHelper( ) { if (K9.isQuietTime) { + builder.setNotificationSilent() return } @@ -34,6 +35,8 @@ class NotificationHelper( if (vibrationPattern != null) { builder.setVibrate(vibrationPattern) } + } else { + builder.setNotificationSilent() } if (ledColor != null) { @@ -75,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 @@ -85,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.java b/app/core/src/main/java/com/fsck/k9/notification/NotificationHolder.java deleted file mode 100644 index b1acf555fbaa4f078a1645a81bfc63ff7eeb8964..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/notification/NotificationHolder.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.fsck.k9.notification; - - -class NotificationHolder { - public final int notificationId; - public final NotificationContent content; - - - public NotificationHolder(int notificationId, NotificationContent content) { - this.notificationId = notificationId; - this.content = content; - } -} 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 new file mode 100644 index 0000000000000000000000000000000000000000..73a3647a18cbb1c7ff27286fad062e066c44cc19 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationHolder.kt @@ -0,0 +1,12 @@ +package com.fsck.k9.notification + +internal data class NotificationHolder( + val notificationId: Int, + val timestamp: Long, + val content: NotificationContent +) + +internal data class InactiveNotificationHolder( + val timestamp: Long, + val content: NotificationContent +) diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationIds.java b/app/core/src/main/java/com/fsck/k9/notification/NotificationIds.java deleted file mode 100644 index e74d7de2f3fd06fbdeec382126b4e2565ab22230..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/notification/NotificationIds.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.fsck.k9.notification; - - -import com.fsck.k9.Account; - - -class NotificationIds { - public static final int PUSH_NOTIFICATION_ID = 1; - private static final int NUMBER_OF_GENERAL_NOTIFICATIONS = 1; - - private static final int OFFSET_SEND_FAILED_NOTIFICATION = 0; - private static final int OFFSET_CERTIFICATE_ERROR_INCOMING = 1; - private static final int OFFSET_CERTIFICATE_ERROR_OUTGOING = 2; - private static final int OFFSET_AUTHENTICATION_ERROR_INCOMING = 3; - private static final int OFFSET_AUTHENTICATION_ERROR_OUTGOING = 4; - private static final int OFFSET_FETCHING_MAIL = 5; - private static final int OFFSET_NEW_MAIL_SUMMARY = 6; - - private static final int OFFSET_NEW_MAIL_STACKED = 7; - - private static final int NUMBER_OF_DEVICE_NOTIFICATIONS = 7; - private static final int NUMBER_OF_STACKED_NOTIFICATIONS = NotificationData.MAX_NUMBER_OF_STACKED_NOTIFICATIONS; - private static final int NUMBER_OF_NOTIFICATIONS_PER_ACCOUNT = NUMBER_OF_DEVICE_NOTIFICATIONS + - NUMBER_OF_STACKED_NOTIFICATIONS; - - - public static int getNewMailSummaryNotificationId(Account account) { - return getBaseNotificationId(account) + OFFSET_NEW_MAIL_SUMMARY; - } - - public static int getNewMailStackedNotificationId(Account account, int index) { - if (index < 0 || index >= NUMBER_OF_STACKED_NOTIFICATIONS) { - throw new IndexOutOfBoundsException("Invalid value: " + index); - } - - return getBaseNotificationId(account) + OFFSET_NEW_MAIL_STACKED + index; - } - - public static int getFetchingMailNotificationId(Account account) { - return getBaseNotificationId(account) + OFFSET_FETCHING_MAIL; - } - - public static int getSendFailedNotificationId(Account account) { - return getBaseNotificationId(account) + OFFSET_SEND_FAILED_NOTIFICATION; - } - - public static int getCertificateErrorNotificationId(Account account, boolean incoming) { - int offset = incoming ? OFFSET_CERTIFICATE_ERROR_INCOMING : OFFSET_CERTIFICATE_ERROR_OUTGOING; - return getBaseNotificationId(account) + offset; - } - - public static int getAuthenticationErrorNotificationId(Account account, boolean incoming) { - int offset = incoming ? OFFSET_AUTHENTICATION_ERROR_INCOMING : OFFSET_AUTHENTICATION_ERROR_OUTGOING; - return getBaseNotificationId(account) + offset; - } - - private static int getBaseNotificationId(Account account) { - return 1 /* skip notification ID 0 */ + NUMBER_OF_GENERAL_NOTIFICATIONS + - account.getAccountNumber() * NUMBER_OF_NOTIFICATIONS_PER_ACCOUNT; - } -} 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 new file mode 100644 index 0000000000000000000000000000000000000000..a074c61774b8d3299f890213de8c5e9d9ea7895a --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationIds.kt @@ -0,0 +1,64 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account + +internal object NotificationIds { + const val PUSH_NOTIFICATION_ID = 1 + + private const val NUMBER_OF_GENERAL_NOTIFICATIONS = 1 + private const val OFFSET_SEND_FAILED_NOTIFICATION = 0 + private const val OFFSET_CERTIFICATE_ERROR_INCOMING = 1 + private const val OFFSET_CERTIFICATE_ERROR_OUTGOING = 2 + private const val OFFSET_AUTHENTICATION_ERROR_INCOMING = 3 + 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_SINGLE = 7 + private const val NUMBER_OF_MISC_ACCOUNT_NOTIFICATIONS = 7 + private const val NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS = MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS + private const val NUMBER_OF_NOTIFICATIONS_PER_ACCOUNT = + NUMBER_OF_MISC_ACCOUNT_NOTIFICATIONS + NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS + + fun getNewMailSummaryNotificationId(account: Account): Int { + return getBaseNotificationId(account) + OFFSET_NEW_MAIL_SUMMARY + } + + 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_SINGLE + index + } + + fun getAllMessageNotificationIds(account: Account): List { + val singleMessageNotificationIdRange = (0 until NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS) + + (getBaseNotificationId(account) + OFFSET_NEW_MAIL_SINGLE) + + return singleMessageNotificationIdRange.toList() + getNewMailSummaryNotificationId(account) + } + + fun getFetchingMailNotificationId(account: Account): Int { + return getBaseNotificationId(account) + OFFSET_FETCHING_MAIL + } + + fun getSendFailedNotificationId(account: Account): Int { + return getBaseNotificationId(account) + OFFSET_SEND_FAILED_NOTIFICATION + } + + + fun getCertificateErrorNotificationId(account: Account, incoming: Boolean): Int { + val offset = if (incoming) OFFSET_CERTIFICATE_ERROR_INCOMING else OFFSET_CERTIFICATE_ERROR_OUTGOING + + return getBaseNotificationId(account) + offset + } + + fun getAuthenticationErrorNotificationId(account: Account, incoming: Boolean): Int { + val offset = if (incoming) OFFSET_AUTHENTICATION_ERROR_INCOMING else OFFSET_AUTHENTICATION_ERROR_OUTGOING + + return getBaseNotificationId(account) + offset + } + + private fun getBaseNotificationId(account: Account): Int { + return 1 /* skip notification ID 0 */ + NUMBER_OF_GENERAL_NOTIFICATIONS + + account.accountNumber * NUMBER_OF_NOTIFICATIONS_PER_ACCOUNT + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationRepository.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationRepository.kt new file mode 100644 index 0000000000000000000000000000000000000000..b18f3686aa7259b3179e3482ad0d55fdff644f86 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationRepository.kt @@ -0,0 +1,111 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.controller.MessageReference +import com.fsck.k9.mailstore.LocalStoreProvider +import com.fsck.k9.mailstore.MessageStoreManager + +internal class NotificationRepository( + private val notificationStoreProvider: NotificationStoreProvider, + private val localStoreProvider: LocalStoreProvider, + private val messageStoreManager: MessageStoreManager, + private val notificationContentCreator: NotificationContentCreator +) { + private val notificationDataStore = NotificationDataStore() + + @Synchronized + fun restoreNotifications(account: Account): NotificationData? { + val localStore = localStoreProvider.getInstance(account) + + val (activeNotificationMessages, inactiveNotificationMessages) = localStore.notificationMessages.partition { + it.notificationId != null + } + + if (activeNotificationMessages.isEmpty()) return null + + val activeNotifications = activeNotificationMessages.map { notificationMessage -> + val content = notificationContentCreator.createFromMessage(account, notificationMessage.message) + NotificationHolder(notificationMessage.notificationId!!, notificationMessage.timestamp, content) + } + + val inactiveNotifications = inactiveNotificationMessages.map { notificationMessage -> + val content = notificationContentCreator.createFromMessage(account, notificationMessage.message) + InactiveNotificationHolder(notificationMessage.timestamp, content) + } + + return notificationDataStore.initializeAccount(account, activeNotifications, inactiveNotifications) + } + + @Synchronized + fun addNotification(account: Account, content: NotificationContent, timestamp: Long): AddNotificationResult { + return notificationDataStore.addNotification(account, content, timestamp).also { result -> + persistNotificationDataStoreChanges(account, result.notificationStoreOperations) + } + } + + @Synchronized + fun removeNotifications( + account: Account, + clearNewMessageState: Boolean = true, + selector: (List) -> List + ): RemoveNotificationsResult? { + return notificationDataStore.removeNotifications(account, selector)?.also { result -> + if (clearNewMessageState) { + persistNotificationDataStoreChanges(account, result.notificationStoreOperations) + } + } + } + + @Synchronized + fun clearNotifications(account: Account, clearNewMessageState: Boolean) { + notificationDataStore.clearNotifications(account) + clearNotificationStore(account) + + if (clearNewMessageState) { + clearNewMessageState(account) + } + } + + private fun persistNotificationDataStoreChanges(account: Account, operations: List) { + val notificationStore = notificationStoreProvider.getNotificationStore(account) + notificationStore.persistNotificationChanges(operations) + + setNewMessageState(account, operations) + } + + private fun setNewMessageState(account: Account, operations: List) { + val messageStore = messageStoreManager.getMessageStore(account) + + for (operation in operations) { + when (operation) { + is NotificationStoreOperation.Add -> { + val messageReference = operation.messageReference + messageStore.setNewMessageState( + folderId = messageReference.folderId, + messageServerId = messageReference.uid, + newMessage = true + ) + } + is NotificationStoreOperation.Remove -> { + val messageReference = operation.messageReference + messageStore.setNewMessageState( + folderId = messageReference.folderId, + messageServerId = messageReference.uid, + newMessage = false + ) + } + else -> Unit + } + } + } + + private fun clearNewMessageState(account: Account) { + val messageStore = messageStoreManager.getMessageStore(account) + messageStore.clearNewMessageState() + } + + private fun clearNotificationStore(account: Account) { + val notificationStore = notificationStoreProvider.getNotificationStore(account) + notificationStore.clearNotifications() + } +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationResourceProvider.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationResourceProvider.kt index adc9eb8f4d9e9d3d2b80f620eabcce46e2dd1343..e558cd5e74e25f26d33cf12bb235fe5daa5bb4ab 100644 --- a/app/core/src/main/java/com/fsck/k9/notification/NotificationResourceProvider.kt +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationResourceProvider.kt @@ -24,6 +24,7 @@ interface NotificationResourceProvider { fun authenticationErrorTitle(): String fun authenticationErrorBody(accountName: String): String + fun certificateErrorTitle(): String fun certificateErrorTitle(accountName: String): String fun certificateErrorBody(): String diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationStore.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationStore.kt new file mode 100644 index 0000000000000000000000000000000000000000..4068bad6ffd7036bec6bfd936853b58635bce61b --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationStore.kt @@ -0,0 +1,6 @@ +package com.fsck.k9.notification + +interface NotificationStore { + fun persistNotificationChanges(operations: List) + fun clearNotifications() +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationStoreOperation.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationStoreOperation.kt new file mode 100644 index 0000000000000000000000000000000000000000..c03620fba3df31cd01837b0ab59fb938bdb3db74 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationStoreOperation.kt @@ -0,0 +1,20 @@ +package com.fsck.k9.notification + +import com.fsck.k9.controller.MessageReference + +sealed interface NotificationStoreOperation { + data class Add( + val messageReference: MessageReference, + val notificationId: Int, + val timestamp: Long + ) : NotificationStoreOperation + + data class Remove(val messageReference: MessageReference) : NotificationStoreOperation + + data class ChangeToInactive(val messageReference: MessageReference) : NotificationStoreOperation + + data class ChangeToActive( + val messageReference: MessageReference, + val notificationId: Int + ) : NotificationStoreOperation +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationStoreProvider.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationStoreProvider.kt new file mode 100644 index 0000000000000000000000000000000000000000..44d04096375e6695b4ba4ecd834e9fb720e406ca --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationStoreProvider.kt @@ -0,0 +1,7 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account + +interface NotificationStoreProvider { + fun getNotificationStore(account: Account): NotificationStore +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/PushNotificationManager.kt b/app/core/src/main/java/com/fsck/k9/notification/PushNotificationManager.kt index c26f7899acf688b66361b2149f464a44ead40183..bd15f47801b7afb7f7d57bbed2db8af025e83ee0 100644 --- a/app/core/src/main/java/com/fsck/k9/notification/PushNotificationManager.kt +++ b/app/core/src/main/java/com/fsck/k9/notification/PushNotificationManager.kt @@ -64,6 +64,7 @@ internal class PushNotificationManager( .setContentIntent(contentIntent) .setOngoing(true) .setNotificationSilent() + .setPriority(NotificationCompat.PRIORITY_MIN) .setBadgeIconType(NotificationCompat.BADGE_ICON_NONE) .setLocalOnly(true) .setShowWhen(false) diff --git a/app/core/src/main/java/com/fsck/k9/notification/RemoveNotificationResult.java b/app/core/src/main/java/com/fsck/k9/notification/RemoveNotificationResult.java deleted file mode 100644 index 7b57b0442c96d56afbe9d19e0a7262782b003b2c..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/notification/RemoveNotificationResult.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.fsck.k9.notification; - - -class RemoveNotificationResult { - private final NotificationHolder notificationHolder; - private final int notificationId; - private final boolean unknownNotification; - - - private RemoveNotificationResult(NotificationHolder notificationHolder, int notificationId, - boolean unknownNotification) { - this.notificationHolder = notificationHolder; - this.notificationId = notificationId; - this.unknownNotification = unknownNotification; - } - - public static RemoveNotificationResult createNotification(NotificationHolder notificationHolder) { - return new RemoveNotificationResult(notificationHolder, notificationHolder.notificationId, false); - } - - public static RemoveNotificationResult cancelNotification(int notificationId) { - return new RemoveNotificationResult(null, notificationId, false); - } - - public static RemoveNotificationResult unknownNotification() { - return new RemoveNotificationResult(null, 0, true); - } - - public boolean shouldCreateNotification() { - return notificationHolder != null; - } - - public int getNotificationId() { - if (isUnknownNotification()) { - throw new IllegalStateException("getNotificationId() can only be called when " + - "isUnknownNotification() returns false"); - } - - return notificationId; - } - - public boolean isUnknownNotification() { - return unknownNotification; - } - - public NotificationHolder getNotificationHolder() { - if (!shouldCreateNotification()) { - throw new IllegalStateException("getNotificationHolder() can only be called when " + - "shouldCreateNotification() returns true"); - } - - return notificationHolder; - } -} diff --git a/app/core/src/main/java/com/fsck/k9/notification/RemoveNotificationsResult.kt b/app/core/src/main/java/com/fsck/k9/notification/RemoveNotificationsResult.kt new file mode 100644 index 0000000000000000000000000000000000000000..f3c2d44bffafb34c002104ece3a081cb9bf9a050 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/RemoveNotificationsResult.kt @@ -0,0 +1,8 @@ +package com.fsck.k9.notification + +internal data class RemoveNotificationsResult( + val notificationData: NotificationData, + val notificationStoreOperations: List, + val notificationHolders: List, + val cancelNotificationIds: List +) 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..067893a8e2ad1193b3864e6b76e33d892117224f --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/SendFailedNotificationController.kt @@ -0,0 +1,66 @@ +package com.fsck.k9.notification + +import android.app.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) + .setColor(account.chipColor) + .setWhen(System.currentTimeMillis()) + .setAutoCancel(true) + .setTicker(title) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(folderListPendingIntent) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setPublicVersion(createLockScreenNotification(account)) + .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 fun createLockScreenNotification(account: Account): Notification { + return notificationHelper + .createNotificationBuilder(account, NotificationChannelManager.ChannelType.MISCELLANEOUS) + .setSmallIcon(resourceProvider.iconWarning) + .setColor(account.chipColor) + .setWhen(System.currentTimeMillis()) + .setContentTitle(resourceProvider.sendFailedTitle()) + .build() + } + + private val notificationManager: NotificationManagerCompat + get() = notificationHelper.getNotificationManager() +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/SendFailedNotifications.java b/app/core/src/main/java/com/fsck/k9/notification/SendFailedNotifications.java deleted file mode 100644 index 2bb63cd5e94b79106f7a4968fb59d35d220916a4..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/notification/SendFailedNotifications.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.fsck.k9.notification; - - -import android.app.PendingIntent; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationCompat.BigTextStyle; -import androidx.core.app.NotificationManagerCompat; - -import com.fsck.k9.Account; -import com.fsck.k9.helper.ExceptionHelper; - -import static com.fsck.k9.notification.NotificationHelper.NOTIFICATION_LED_BLINK_FAST; -import static com.fsck.k9.notification.NotificationHelper.NOTIFICATION_LED_FAILURE_COLOR; - - -class SendFailedNotifications { - private final NotificationHelper notificationHelper; - private final NotificationActionCreator actionBuilder; - private final NotificationResourceProvider resourceProvider; - - - public SendFailedNotifications(NotificationHelper notificationHelper, NotificationActionCreator actionBuilder, - NotificationResourceProvider resourceProvider) { - this.notificationHelper = notificationHelper; - this.actionBuilder = actionBuilder; - this.resourceProvider = resourceProvider; - } - - public void showSendFailedNotification(Account account, Exception exception) { - String title = resourceProvider.sendFailedTitle(); - String text = ExceptionHelper.getRootCauseMessage(exception); - - int notificationId = NotificationIds.getSendFailedNotificationId(account); - PendingIntent folderListPendingIntent = actionBuilder.createViewFolderListPendingIntent( - account, notificationId); - - NotificationCompat.Builder builder = notificationHelper - .createNotificationBuilder(account, NotificationChannelManager.ChannelType.MISCELLANEOUS) - .setSmallIcon(resourceProvider.getIconWarning()) - .setWhen(System.currentTimeMillis()) - .setAutoCancel(true) - .setTicker(title) - .setContentTitle(title) - .setContentText(text) - .setContentIntent(folderListPendingIntent) - .setStyle(new BigTextStyle().bigText(text)) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setCategory(NotificationCompat.CATEGORY_ERROR); - - notificationHelper.configureNotification(builder, null, null, NOTIFICATION_LED_FAILURE_COLOR, - NOTIFICATION_LED_BLINK_FAST, true); - - getNotificationManager().notify(notificationId, builder.build()); - } - - public void clearSendFailedNotification(Account account) { - int notificationId = NotificationIds.getSendFailedNotificationId(account); - getNotificationManager().cancel(notificationId); - } - - private NotificationManagerCompat getNotificationManager() { - return 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..c9012752fbca9f0dfc46a3341371ff14e311d22c --- /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.activeNotifications.first().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/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..77e1272826515e46aaccca190d9bc1e62f82b56d --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/SummaryNotificationDataCreator.kt @@ -0,0 +1,94 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.K9 + +private const val MAX_NUMBER_OF_MESSAGES_FOR_SUMMARY_NOTIFICATION = 5 + +internal class SummaryNotificationDataCreator( + private val singleMessageNotificationDataCreator: SingleMessageNotificationDataCreator +) { + fun createSummaryNotificationData(data: NotificationData, silent: Boolean): SummaryNotificationData { + val timestamp = data.latestTimestamp + 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 = data.summaryContent, + additionalMessagesCount = data.additionalMessagesCount, + messageReferences = data.messageReferences, + actions = createSummaryNotificationActions(), + wearActions = createSummaryWearNotificationActions(data.account) + ) + } + + @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 + } + + private val NotificationData.latestTimestamp: Long + get() = activeNotifications.first().timestamp + + private val NotificationData.summaryContent: List + get() { + return activeNotifications.asSequence() + .map { it.content.summary } + .take(MAX_NUMBER_OF_MESSAGES_FOR_SUMMARY_NOTIFICATION) + .toList() + } + + private val NotificationData.additionalMessagesCount: Int + get() = (newMessagesCount - MAX_NUMBER_OF_MESSAGES_FOR_SUMMARY_NOTIFICATION).coerceAtLeast(0) +} 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..4d4390ef5294504c16beb9925450338d707a4437 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/notification/SyncNotificationController.kt @@ -0,0 +1,157 @@ +package com.fsck.k9.notification + +import android.app.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) + .setColor(account.chipColor) + .setWhen(System.currentTimeMillis()) + .setOngoing(true) + .setTicker(tickerText) + .setContentTitle(title) + .setContentText(accountName) + .setContentIntent(showMessageListPendingIntent) + .setPublicVersion(createSendingLockScreenNotification(account)) + + 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) + .setColor(account.chipColor) + .setWhen(System.currentTimeMillis()) + .setOngoing(true) + .setTicker(tickerText) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(showMessageListPendingIntent) + .setPublicVersion(createFetchingMailLockScreenNotification(account)) + .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) + .setColor(account.chipColor) + .setWhen(System.currentTimeMillis()) + .setOngoing(true) + .setContentTitle(title) + .setContentText(text) + .setPublicVersion(createFetchingMailLockScreenNotification(account)) + .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 fun createSendingLockScreenNotification(account: Account): Notification { + return notificationHelper + .createNotificationBuilder(account, NotificationChannelManager.ChannelType.MISCELLANEOUS) + .setSmallIcon(resourceProvider.iconSendingMail) + .setColor(account.chipColor) + .setWhen(System.currentTimeMillis()) + .setContentTitle(resourceProvider.sendingMailTitle()) + .build() + } + + private fun createFetchingMailLockScreenNotification(account: Account): Notification { + return notificationHelper + .createNotificationBuilder(account, NotificationChannelManager.ChannelType.MISCELLANEOUS) + .setSmallIcon(resourceProvider.iconCheckingMail) + .setColor(account.chipColor) + .setWhen(System.currentTimeMillis()) + .setContentTitle(resourceProvider.checkingMailTitle()) + .build() + } + + private val notificationManager: NotificationManagerCompat + get() = notificationHelper.getNotificationManager() +} diff --git a/app/core/src/main/java/com/fsck/k9/notification/SyncNotifications.java b/app/core/src/main/java/com/fsck/k9/notification/SyncNotifications.java deleted file mode 100644 index 16a58882034bc982294533464c14507078042407..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/notification/SyncNotifications.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.fsck.k9.notification; - - -import android.app.PendingIntent; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; - -import com.fsck.k9.Account; -import com.fsck.k9.mailstore.LocalFolder; - -import static com.fsck.k9.notification.NotificationHelper.NOTIFICATION_LED_BLINK_FAST; - - -class SyncNotifications { - private static final boolean NOTIFICATION_LED_WHILE_SYNCING = false; - - - private final NotificationHelper notificationHelper; - private final NotificationActionCreator actionBuilder; - private final NotificationResourceProvider resourceProvider; - - - public SyncNotifications(NotificationHelper notificationHelper, NotificationActionCreator actionBuilder, - NotificationResourceProvider resourceProvider) { - this.notificationHelper = notificationHelper; - this.actionBuilder = actionBuilder; - this.resourceProvider = resourceProvider; - } - - public void showSendingNotification(Account account) { - String accountName = notificationHelper.getAccountName(account); - String title = resourceProvider.sendingMailTitle(); - String tickerText = resourceProvider.sendingMailBody(accountName); - - int notificationId = NotificationIds.getFetchingMailNotificationId(account); - long outboxFolderId = account.getOutboxFolderId(); - PendingIntent showMessageListPendingIntent = actionBuilder.createViewFolderPendingIntent( - account, outboxFolderId, notificationId); - - NotificationCompat.Builder builder = notificationHelper.createNotificationBuilder(account, - NotificationChannelManager.ChannelType.MISCELLANEOUS) - .setSmallIcon(resourceProvider.getIconSendingMail()) - .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, null, null, - account.getNotificationSetting().getLedColor(), - NOTIFICATION_LED_BLINK_FAST, true); - } - - getNotificationManager().notify(notificationId, builder.build()); - } - - public void clearSendingNotification(Account account) { - int notificationId = NotificationIds.getFetchingMailNotificationId(account); - getNotificationManager().cancel(notificationId); - } - - public void showFetchingMailNotification(Account account, LocalFolder folder) { - String accountName = account.getDescription(); - long folderId = folder.getDatabaseId(); - String folderName = folder.getName(); - - String tickerText = resourceProvider.checkingMailTicker(accountName, folderName); - String title = resourceProvider.checkingMailTitle(); - //TODO: Use format string from resources - String text = accountName + resourceProvider.checkingMailSeparator() + folderName; - - int notificationId = NotificationIds.getFetchingMailNotificationId(account); - PendingIntent showMessageListPendingIntent = actionBuilder.createViewFolderPendingIntent( - account, folderId, notificationId); - - NotificationCompat.Builder builder = notificationHelper.createNotificationBuilder(account, - NotificationChannelManager.ChannelType.MISCELLANEOUS) - .setSmallIcon(resourceProvider.getIconCheckingMail()) - .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, null, null, - account.getNotificationSetting().getLedColor(), - NOTIFICATION_LED_BLINK_FAST, true); - } - - getNotificationManager().notify(notificationId, builder.build()); - } - - public void clearFetchingMailNotification(Account account) { - int notificationId = NotificationIds.getFetchingMailNotificationId(account); - getNotificationManager().cancel(notificationId); - } - - private NotificationManagerCompat getNotificationManager() { - return notificationHelper.getNotificationManager(); - } -} diff --git a/app/core/src/main/java/com/fsck/k9/notification/WearNotifications.java b/app/core/src/main/java/com/fsck/k9/notification/WearNotifications.java deleted file mode 100644 index 8eba134d5e41c9250ad8962fa200b692cc02f12e..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/notification/WearNotifications.java +++ /dev/null @@ -1,253 +0,0 @@ -package com.fsck.k9.notification; - - -import java.util.ArrayList; - -import android.app.Notification; -import android.app.PendingIntent; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationCompat.Builder; -import androidx.core.app.NotificationCompat.WearableExtender; - -import com.fsck.k9.Account; -import com.fsck.k9.K9; -import com.fsck.k9.controller.MessageReference; -import com.fsck.k9.controller.MessagingController; - - -class WearNotifications extends BaseNotifications { - - public WearNotifications(NotificationHelper notificationHelper, NotificationActionCreator actionCreator, - NotificationResourceProvider resourceProvider) { - super(notificationHelper, actionCreator, resourceProvider); - } - - public Notification buildStackedNotification(Account account, NotificationHolder holder) { - int notificationId = holder.notificationId; - NotificationContent content = holder.content; - NotificationCompat.Builder builder = createBigTextStyleNotification(account, holder, notificationId); - - PendingIntent deletePendingIntent = actionCreator.createDismissMessagePendingIntent( - context, content.messageReference, holder.notificationId); - builder.setDeleteIntent(deletePendingIntent); - - addActions(builder, account, holder); - - return builder.build(); - } - - - public void addSummaryActions(Builder builder, NotificationData notificationData) { - NotificationCompat.WearableExtender wearableExtender = new NotificationCompat.WearableExtender(); - - addMarkAllAsReadAction(wearableExtender, notificationData); - - if (isDeleteActionAvailableForWear()) { - addDeleteAllAction(wearableExtender, notificationData); - } - - Account account = notificationData.getAccount(); - if (isArchiveActionAvailableForWear(account)) { - addArchiveAllAction(wearableExtender, notificationData); - } - - builder.extend(wearableExtender); - } - - private void addMarkAllAsReadAction(WearableExtender wearableExtender, NotificationData notificationData) { - int icon = resourceProvider.getWearIconMarkAsRead(); - String title = resourceProvider.actionMarkAllAsRead(); - - Account account = notificationData.getAccount(); - ArrayList messageReferences = notificationData.getAllMessageReferences(); - int notificationId = NotificationIds.getNewMailSummaryNotificationId(account); - PendingIntent action = actionCreator.createMarkAllAsReadPendingIntent( - account, messageReferences, notificationId); - - NotificationCompat.Action markAsReadAction = new NotificationCompat.Action.Builder(icon, title, action).build(); - wearableExtender.addAction(markAsReadAction); - } - - private void addDeleteAllAction(WearableExtender wearableExtender, NotificationData notificationData) { - int icon = resourceProvider.getWearIconDelete(); - String title = resourceProvider.actionDeleteAll(); - - Account account = notificationData.getAccount(); - ArrayList messageReferences = notificationData.getAllMessageReferences(); - int notificationId = NotificationIds.getNewMailSummaryNotificationId(account); - PendingIntent action = actionCreator.createDeleteAllPendingIntent(account, messageReferences, notificationId); - - NotificationCompat.Action deleteAction = new NotificationCompat.Action.Builder(icon, title, action).build(); - wearableExtender.addAction(deleteAction); - } - - private void addArchiveAllAction(WearableExtender wearableExtender, NotificationData notificationData) { - int icon = resourceProvider.getWearIconArchive(); - String title = resourceProvider.actionArchiveAll(); - - Account account = notificationData.getAccount(); - ArrayList messageReferences = notificationData.getAllMessageReferences(); - int notificationId = NotificationIds.getNewMailSummaryNotificationId(account); - PendingIntent action = actionCreator.createArchiveAllPendingIntent(account, messageReferences, notificationId); - - NotificationCompat.Action archiveAction = new NotificationCompat.Action.Builder(icon, title, action).build(); - wearableExtender.addAction(archiveAction); - } - - private void addActions(Builder builder, Account account, NotificationHolder holder) { - addDeviceActions(builder, holder); - addWearActions(builder, account, holder); - } - - private void addDeviceActions(Builder builder, NotificationHolder holder) { - addDeviceReplyAction(builder, holder); - addDeviceMarkAsReadAction(builder, holder); - addDeviceDeleteAction(builder, holder); - } - - private void addDeviceReplyAction(Builder builder, NotificationHolder holder) { - int icon = resourceProvider.getIconReply(); - String title = resourceProvider.actionReply(); - - NotificationContent content = holder.content; - MessageReference messageReference = content.messageReference; - PendingIntent replyToMessagePendingIntent = - actionCreator.createReplyPendingIntent(messageReference, holder.notificationId); - - builder.addAction(icon, title, replyToMessagePendingIntent); - } - - private void addDeviceMarkAsReadAction(Builder builder, NotificationHolder holder) { - int icon = resourceProvider.getIconMarkAsRead(); - String title = resourceProvider.actionMarkAsRead(); - - NotificationContent content = holder.content; - int notificationId = holder.notificationId; - MessageReference messageReference = content.messageReference; - PendingIntent action = actionCreator.createMarkMessageAsReadPendingIntent(messageReference, notificationId); - - builder.addAction(icon, title, action); - } - - private void addDeviceDeleteAction(Builder builder, NotificationHolder holder) { - if (!isDeleteActionEnabled()) { - return; - } - - int icon = resourceProvider.getIconDelete(); - String title = resourceProvider.actionDelete(); - - NotificationContent content = holder.content; - int notificationId = holder.notificationId; - MessageReference messageReference = content.messageReference; - PendingIntent action = actionCreator.createDeleteMessagePendingIntent(messageReference, notificationId); - - builder.addAction(icon, title, action); - } - - private void addWearActions(Builder builder, Account account, NotificationHolder holder) { - NotificationCompat.WearableExtender wearableExtender = new 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 void addReplyAction(WearableExtender wearableExtender, NotificationHolder holder) { - int icon = resourceProvider.getWearIconReplyAll(); - String title = resourceProvider.actionReply(); - - MessageReference messageReference = holder.content.messageReference; - int notificationId = holder.notificationId; - PendingIntent action = actionCreator.createReplyPendingIntent(messageReference, notificationId); - - NotificationCompat.Action replyAction = new NotificationCompat.Action.Builder(icon, title, action).build(); - wearableExtender.addAction(replyAction); - } - - private void addMarkAsReadAction(WearableExtender wearableExtender, NotificationHolder holder) { - int icon = resourceProvider.getWearIconMarkAsRead(); - String title = resourceProvider.actionMarkAsRead(); - - MessageReference messageReference = holder.content.messageReference; - int notificationId = holder.notificationId; - PendingIntent action = actionCreator.createMarkMessageAsReadPendingIntent(messageReference, notificationId); - - NotificationCompat.Action markAsReadAction = new NotificationCompat.Action.Builder(icon, title, action).build(); - wearableExtender.addAction(markAsReadAction); - } - - private void addDeleteAction(WearableExtender wearableExtender, NotificationHolder holder) { - int icon = resourceProvider.getWearIconDelete(); - String title = resourceProvider.actionDelete(); - - MessageReference messageReference = holder.content.messageReference; - int notificationId = holder.notificationId; - PendingIntent action = actionCreator.createDeleteMessagePendingIntent(messageReference, notificationId); - - NotificationCompat.Action deleteAction = new NotificationCompat.Action.Builder(icon, title, action).build(); - wearableExtender.addAction(deleteAction); - } - - private void addArchiveAction(WearableExtender wearableExtender, NotificationHolder holder) { - int icon = resourceProvider.getWearIconArchive(); - String title = resourceProvider.actionArchive(); - - MessageReference messageReference = holder.content.messageReference; - int notificationId = holder.notificationId; - PendingIntent action = actionCreator.createArchiveMessagePendingIntent(messageReference, notificationId); - - NotificationCompat.Action archiveAction = new NotificationCompat.Action.Builder(icon, title, action).build(); - wearableExtender.addAction(archiveAction); - } - - private void addMarkAsSpamAction(WearableExtender wearableExtender, NotificationHolder holder) { - int icon = resourceProvider.getWearIconMarkAsSpam(); - String title = resourceProvider.actionMarkAsSpam(); - - MessageReference messageReference = holder.content.messageReference; - int notificationId = holder.notificationId; - PendingIntent action = actionCreator.createMarkMessageAsSpamPendingIntent(messageReference, notificationId); - - NotificationCompat.Action spamAction = new NotificationCompat.Action.Builder(icon, title, action).build(); - wearableExtender.addAction(spamAction); - } - - private boolean isDeleteActionAvailableForWear() { - return isDeleteActionEnabled() && !K9.isConfirmDeleteFromNotification(); - } - - private boolean isArchiveActionAvailableForWear(Account account) { - return isMovePossible(account, account.getArchiveFolderId()); - } - - private boolean isSpamActionAvailableForWear(Account account) { - return !K9.isConfirmSpam() && isMovePossible(account, account.getSpamFolderId()); - } - - private boolean isMovePossible(Account account, Long destinationFolderId) { - if (destinationFolderId == null) { - return false; - } - - MessagingController controller = createMessagingController(); - return controller.isMoveCapable(account); - } - - MessagingController createMessagingController() { - return MessagingController.getInstance(context); - } -} 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/power/TracingPowerManager.java b/app/core/src/main/java/com/fsck/k9/power/TracingPowerManager.java deleted file mode 100644 index 914311aeabefe02767d31a924e06dd06babb5583..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/power/TracingPowerManager.java +++ /dev/null @@ -1,167 +0,0 @@ -package com.fsck.k9.power; - - -import java.util.Timer; -import java.util.TimerTask; -import java.util.concurrent.atomic.AtomicInteger; - -import android.content.Context; -import android.os.PowerManager; -import android.os.PowerManager.WakeLock; -import android.os.SystemClock; - -import com.fsck.k9.mail.K9MailLib; -import org.jetbrains.annotations.NotNull; -import timber.log.Timber; - - -public class TracingPowerManager implements com.fsck.k9.mail.power.PowerManager { - private final static boolean TRACE = false; - public static AtomicInteger wakeLockId = new AtomicInteger(0); - PowerManager pm = null; - private static TracingPowerManager tracingPowerManager; - private Timer timer = null; - - public static synchronized TracingPowerManager getPowerManager(Context context) { - Context appContext = context.getApplicationContext(); - if (tracingPowerManager == null) { - if (K9MailLib.isDebug()) { - Timber.v("Creating TracingPowerManager"); - } - tracingPowerManager = new TracingPowerManager(appContext); - } - return tracingPowerManager; - } - - - private TracingPowerManager(Context context) { - pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE); - if (TRACE) { - timer = new Timer(); - } - } - - @NotNull - @Override - public com.fsck.k9.mail.power.WakeLock newWakeLock(@NotNull String tag) { - return new TracingWakeLock(PowerManager.PARTIAL_WAKE_LOCK, tag); - } - - public TracingWakeLock newWakeLock(int flags, String tag) { - return new TracingWakeLock(flags, tag); - } - - - public class TracingWakeLock implements com.fsck.k9.mail.power.WakeLock { - final WakeLock wakeLock; - final int id; - final String tag; - volatile TimerTask timerTask; - volatile Long startTime = null; - volatile Long timeout = null; - - public TracingWakeLock(int flags, String ntag) { - tag = ntag; - wakeLock = pm.newWakeLock(flags, tag); - id = wakeLockId.getAndIncrement(); - if (K9MailLib.isDebug()) { - Timber.v("TracingWakeLock for tag %s / id %d: Create", tag, id); - } - } - - @Override - public void acquire(long timeout) { - synchronized (wakeLock) { - wakeLock.acquire(timeout); - } - if (K9MailLib.isDebug()) { - Timber.v("TracingWakeLock for tag %s / id %d for %d ms: acquired", tag, id, timeout); - } - raiseNotification(); - if (startTime == null) { - startTime = SystemClock.elapsedRealtime(); - } - this.timeout = timeout; - } - - @Override - public void acquire() { - synchronized (wakeLock) { - wakeLock.acquire(); - } - raiseNotification(); - if (K9MailLib.isDebug()) { - Timber.w("TracingWakeLock for tag %s / id %d: acquired with no timeout. K-9 Mail should not do this", - tag, id); - } - if (startTime == null) { - startTime = SystemClock.elapsedRealtime(); - } - timeout = null; - } - - @Override - public void setReferenceCounted(boolean counted) { - synchronized (wakeLock) { - wakeLock.setReferenceCounted(counted); - } - } - - @Override - public void release() { - if (startTime != null) { - Long endTime = SystemClock.elapsedRealtime(); - if (K9MailLib.isDebug()) { - Timber.v("TracingWakeLock for tag %s / id %d: releasing after %d ms, timeout = %d ms", - tag, id, endTime - startTime, timeout); - } - } else { - if (K9MailLib.isDebug()) { - Timber.v("TracingWakeLock for tag %s / id %d, timeout = %d ms: releasing", tag, id, timeout); - } - } - cancelNotification(); - synchronized (wakeLock) { - wakeLock.release(); - } - startTime = null; - } - - private void cancelNotification() { - if (timer != null) { - synchronized (timer) { - if (timerTask != null) { - timerTask.cancel(); - } - } - } - } - - private void raiseNotification() { - if (timer != null) { - synchronized (timer) { - if (timerTask != null) { - timerTask.cancel(); - timerTask = null; - } - timerTask = new TimerTask() { - @Override - public void run() { - if (startTime != null) { - Long endTime = SystemClock.elapsedRealtime(); - Timber.i("TracingWakeLock for tag %s / id %d: has been active for %d ms, timeout = %d ms", - tag, id, endTime - startTime, timeout); - - } else { - Timber.i("TracingWakeLock for tag %s / id %d: still active, timeout = %d ms", - tag, id, timeout); - } - } - - }; - timer.schedule(timerTask, 1000, 1000); - } - } - } - } -} diff --git a/app/core/src/main/java/com/fsck/k9/preferences/AccountManager.kt b/app/core/src/main/java/com/fsck/k9/preferences/AccountManager.kt index d152ab70569e5d306e6d5419f395534c3abad258..5ae56c7e1d235458c7a47d2cc9d204193821e60a 100644 --- a/app/core/src/main/java/com/fsck/k9/preferences/AccountManager.kt +++ b/app/core/src/main/java/com/fsck/k9/preferences/AccountManager.kt @@ -6,6 +6,7 @@ import com.fsck.k9.AccountsChangeListener import kotlinx.coroutines.flow.Flow interface AccountManager { + fun getAccountsFlow(): Flow> fun getAccount(accountUuid: String): Account? fun getAccountFlow(accountUuid: String): Flow fun addAccountRemovedListener(listener: AccountRemovedListener) 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 f02acea45248e46f748a69448aecb8375a83a730..04f2df62ce2682017cff0121b9367c220e7057e0 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 @@ -60,7 +60,8 @@ public class AccountSettingsDescriptions { new V(53, new StringSetting(null)) )); s.put("autoExpandFolderName", Settings.versions( - new V(1, new StringSetting("INBOX")) + new V(1, new StringSetting("INBOX")), + new V(78, new StringSetting(null)) )); s.put("automaticCheckIntervalMinutes", Settings.versions( new V(1, new IntegerResourceSetting(-1, R.array.check_frequency_values)), @@ -216,7 +217,7 @@ public class AccountSettingsDescriptions { new V(1, new IntegerResourceSetting(0, R.array.vibrate_pattern_values)) )); s.put("vibrateTimes", Settings.versions( - new V(1, new IntegerResourceSetting(5, R.array.vibrate_times_label)) + new V(1, new IntegerRangeSetting(1, 10, 5)) )); s.put("allowRemoteSearch", Settings.versions( new V(18, new BooleanSetting(true)) @@ -261,6 +262,9 @@ public class AccountSettingsDescriptions { s.put("trashFolderSelection", Settings.versions( new V(54, new EnumSetting<>(SpecialFolderSelection.class, SpecialFolderSelection.AUTOMATIC)) )); + s.put("ignoreChatMessages", Settings.versions( + new V(76, new BooleanSetting(false)) + )); // note that there is no setting for openPgpProvider, because this will have to be set up together // with the actual provider after import anyways. @@ -410,8 +414,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/FolderSettingsProvider.kt b/app/core/src/main/java/com/fsck/k9/preferences/FolderSettingsProvider.kt index 8485a0752fbc7efa0fd045643e2004ab6249885e..559a4ceb06453c1c6e1824a061c83c0861e83349 100644 --- a/app/core/src/main/java/com/fsck/k9/preferences/FolderSettingsProvider.kt +++ b/app/core/src/main/java/com/fsck/k9/preferences/FolderSettingsProvider.kt @@ -2,13 +2,12 @@ package com.fsck.k9.preferences import com.fsck.k9.Account import com.fsck.k9.mail.FolderClass -import com.fsck.k9.mailstore.FolderRepositoryManager +import com.fsck.k9.mailstore.FolderRepository import com.fsck.k9.mailstore.RemoteFolderDetails -class FolderSettingsProvider(private val folderRepositoryManager: FolderRepositoryManager) { +class FolderSettingsProvider(private val folderRepository: FolderRepository) { fun getFolderSettings(account: Account): List { - val folderRepository = folderRepositoryManager.getFolderRepository(account) - return folderRepository.getRemoteFolderDetails() + return folderRepository.getRemoteFolderDetails(account) .filterNot { it.containsOnlyDefaultValues() } .map { it.toFolderSettings() } } diff --git a/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettings.kt b/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettings.kt index cd486f974015de1b495f22ecef85a20bbabbfa84..927f04afa9bc1503692e57152ca16be308cc9575 100644 --- a/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettings.kt +++ b/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettings.kt @@ -12,7 +12,11 @@ package com.fsck.k9.preferences // TODO: Move over settings from K9 data class GeneralSettings( val backgroundSync: BackgroundSync, - val showRecentChanges: Boolean + val showRecentChanges: Boolean, + val appTheme: AppTheme, + val messageViewTheme: SubTheme, + val messageComposeTheme: SubTheme, + val fixedMessageViewTheme: Boolean ) enum class BackgroundSync { @@ -20,3 +24,15 @@ enum class BackgroundSync { NEVER, FOLLOW_SYSTEM_AUTO_SYNC } + +enum class AppTheme { + LIGHT, + DARK, + FOLLOW_SYSTEM +} + +enum class SubTheme { + LIGHT, + DARK, + USE_GLOBAL +} 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 d0cd53220475dd3110926db64a3f0589439f312a..7131e174160c804dd9e185d60601cdf15a0ba2c2 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 @@ -16,11 +16,8 @@ import com.fsck.k9.Account.SortType; import com.fsck.k9.DI; import com.fsck.k9.FontSizes; import com.fsck.k9.K9; -import com.fsck.k9.K9.NotificationHideSubject; import com.fsck.k9.K9.NotificationQuickDelete; import com.fsck.k9.K9.SplitViewMode; -import com.fsck.k9.K9.AppTheme; -import com.fsck.k9.K9.SubTheme; import com.fsck.k9.core.R; import com.fsck.k9.preferences.Settings.BooleanSetting; import com.fsck.k9.preferences.Settings.ColorSetting; @@ -132,10 +129,6 @@ public class GeneralSettingsDescriptions { new V(1, new BooleanSetting(false)), new V(69, null) )); - s.put("keyguardPrivacy", Settings.versions( - new V(1, new BooleanSetting(false)), - new V(12, null) - )); s.put("language", Settings.versions( new V(1, new LanguageSetting()) )); @@ -195,9 +188,6 @@ public class GeneralSettingsDescriptions { s.put("useVolumeKeysForNavigation", Settings.versions( new V(1, new BooleanSetting(false)) )); - s.put("notificationHideSubject", Settings.versions( - new V(12, new EnumSetting<>(NotificationHideSubject.class, NotificationHideSubject.NEVER)) - )); s.put("useBackgroundAsUnreadIndicator", Settings.versions( new V(19, new BooleanSetting(true)), new V(59, new BooleanSetting(false)) @@ -283,11 +273,13 @@ public class GeneralSettingsDescriptions { s.put("showRecentChanges", Settings.versions( new V(73, new BooleanSetting(true)) )); + s.put("showStarredCount", Settings.versions( + new V(75, new BooleanSetting(false)) + )); SETTINGS = Collections.unmodifiableMap(s); Map u = new HashMap<>(); - u.put(12, new SettingsUpgraderV12()); u.put(24, new SettingsUpgraderV24()); u.put(31, new SettingsUpgraderV31()); u.put(58, new SettingsUpgraderV58()); @@ -319,27 +311,6 @@ public class GeneralSettingsDescriptions { return result; } - /** - * Upgrades the settings from version 11 to 12 - * - * Map the 'keyguardPrivacy' value to the new NotificationHideSubject enum. - */ - private static class SettingsUpgraderV12 implements SettingsUpgrader { - - @Override - public Set upgrade(Map settings) { - Boolean keyguardPrivacy = (Boolean) settings.get("keyguardPrivacy"); - if (keyguardPrivacy != null && keyguardPrivacy) { - // current setting: only show subject when unlocked - settings.put("notificationHideSubject", NotificationHideSubject.WHEN_LOCKED); - } else { - // always show subject [old default] - settings.put("notificationHideSubject", NotificationHideSubject.NEVER); - } - return new HashSet<>(Collections.singletonList("keyguardPrivacy")); - } - } - /** * Upgrades the settings from version 23 to 24. * @@ -490,7 +461,7 @@ public class GeneralSettingsDescriptions { @Override public AppTheme fromString(String value) throws InvalidSettingValueException { try { - return K9.AppTheme.valueOf(value); + return AppTheme.valueOf(value); } catch (IllegalArgumentException e) { throw new InvalidSettingValueException(); } diff --git a/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsManager.kt b/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsManager.kt index b0fc3c097ab6aec533f5118531363865c777e5a8..2c9c4e2eaeb7f45d7cf987f24e9a9295f0e99f95 100644 --- a/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsManager.kt +++ b/app/core/src/main/java/com/fsck/k9/preferences/GeneralSettingsManager.kt @@ -12,4 +12,8 @@ interface GeneralSettingsManager { fun getSettingsFlow(): Flow fun setShowRecentChanges(showRecentChanges: Boolean) + fun setAppTheme(appTheme: AppTheme) + fun setMessageViewTheme(subTheme: SubTheme) + fun setMessageComposeTheme(subTheme: SubTheme) + fun setFixedMessageViewTheme(fixedMessageViewTheme: Boolean) } diff --git a/app/core/src/main/java/com/fsck/k9/preferences/KoinModule.kt b/app/core/src/main/java/com/fsck/k9/preferences/KoinModule.kt index 29acdbdb2a6c472872e9a55593d440b5acc18fca..8d1ca4c88a633ac335888327a37734678521a2b3 100644 --- a/app/core/src/main/java/com/fsck/k9/preferences/KoinModule.kt +++ b/app/core/src/main/java/com/fsck/k9/preferences/KoinModule.kt @@ -11,10 +11,10 @@ val preferencesModule = module { contentResolver = get(), preferences = get(), folderSettingsProvider = get(), - folderRepositoryManager = get() + folderRepository = get() ) } - factory { FolderSettingsProvider(folderRepositoryManager = get()) } + factory { FolderSettingsProvider(folderRepository = get()) } factory { get() } single { RealGeneralSettingsManager( diff --git a/app/core/src/main/java/com/fsck/k9/preferences/RealGeneralSettingsManager.kt b/app/core/src/main/java/com/fsck/k9/preferences/RealGeneralSettingsManager.kt index b3a17c3e88344460a41196c4b19f68a0f632898e..93b5ed8699d4d550b59d908f9ab19c3ef35a27d6 100644 --- a/app/core/src/main/java/com/fsck/k9/preferences/RealGeneralSettingsManager.kt +++ b/app/core/src/main/java/com/fsck/k9/preferences/RealGeneralSettingsManager.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch +import timber.log.Timber /** * Retrieve and modify general settings. @@ -99,8 +100,32 @@ internal class RealGeneralSettingsManager( getSettings().copy(showRecentChanges = showRecentChanges).persist() } + @Synchronized + override fun setAppTheme(appTheme: AppTheme) { + getSettings().copy(appTheme = appTheme).persist() + } + + @Synchronized + override fun setMessageViewTheme(subTheme: SubTheme) { + getSettings().copy(messageViewTheme = subTheme).persist() + } + + @Synchronized + override fun setMessageComposeTheme(subTheme: SubTheme) { + getSettings().copy(messageComposeTheme = subTheme).persist() + } + + @Synchronized + override fun setFixedMessageViewTheme(fixedMessageViewTheme: Boolean) { + getSettings().copy(fixedMessageViewTheme = fixedMessageViewTheme).persist() + } + private fun writeSettings(editor: StorageEditor, settings: GeneralSettings) { editor.putBoolean("showRecentChanges", settings.showRecentChanges) + editor.putEnum("theme", settings.appTheme) + editor.putEnum("messageViewTheme", settings.messageViewTheme) + editor.putEnum("messageComposeTheme", settings.messageComposeTheme) + editor.putBoolean("fixedMessageViewTheme", settings.fixedMessageViewTheme) } private fun loadGeneralSettings(): GeneralSettings { @@ -108,7 +133,11 @@ internal class RealGeneralSettingsManager( val settings = GeneralSettings( backgroundSync = K9.backgroundOps.toBackgroundSync(), - showRecentChanges = storage.getBoolean("showRecentChanges", true) + showRecentChanges = storage.getBoolean("showRecentChanges", true), + appTheme = storage.getEnum("theme", AppTheme.FOLLOW_SYSTEM), + messageViewTheme = storage.getEnum("messageViewTheme", SubTheme.USE_GLOBAL), + messageComposeTheme = storage.getEnum("messageComposeTheme", SubTheme.USE_GLOBAL), + fixedMessageViewTheme = storage.getBoolean("fixedMessageViewTheme", true) ) updateSettingsFlow(settings) @@ -124,3 +153,21 @@ private fun K9.BACKGROUND_OPS.toBackgroundSync(): BackgroundSync { K9.BACKGROUND_OPS.WHEN_CHECKED_AUTO_SYNC -> BackgroundSync.FOLLOW_SYSTEM_AUTO_SYNC } } + +private inline fun > Storage.getEnum(key: String, defaultValue: T): T { + return try { + val value = getString(key, null) + if (value != null) { + enumValueOf(value) + } else { + defaultValue + } + } catch (e: Exception) { + Timber.e("Couldn't read setting '%s'. Using default value instead.", key) + defaultValue + } +} + +private fun > StorageEditor.putEnum(key: String, value: T) { + putString(key, value.name) +} 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 d1bedc6c8006e6f129ce84fe2fdb3bd48aaa8505..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,10 +1,8 @@ package com.fsck.k9.preferences -import java.util.Locale - object ServerTypeConverter { @JvmStatic - fun toServerSettingsType(exportType: String): String = exportType.toLowerCase(Locale.ROOT) + fun toServerSettingsType(exportType: String): String = exportType.lowercase() @JvmStatic fun fromServerSettingsType(serverSettingsType: String): String = when (serverSettingsType) { diff --git a/app/core/src/main/java/com/fsck/k9/preferences/Settings.java b/app/core/src/main/java/com/fsck/k9/preferences/Settings.java index 08ace7bc54f9f50aa597f96b77bbd3832e2b0383..67205834947cf687b63ce97055e3ca4a89f81309 100644 --- a/app/core/src/main/java/com/fsck/k9/preferences/Settings.java +++ b/app/core/src/main/java/com/fsck/k9/preferences/Settings.java @@ -36,7 +36,7 @@ public class Settings { * * @see SettingsExporter */ - public static final int VERSION = 74; + public static final int VERSION = 78; static Map validate(int version, Map> settings, Map importedSettings, boolean useDefaultValues) { 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 8fcdb6063fa02c8d5340f1905d685cc6956427c8..0e879125dfbd519aeace8aa0016d4f8e1b08b963 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 @@ -10,13 +10,13 @@ import com.fsck.k9.AccountPreferenceSerializer.Companion.IDENTITY_EMAIL_KEY import com.fsck.k9.AccountPreferenceSerializer.Companion.IDENTITY_NAME_KEY import com.fsck.k9.Preferences import com.fsck.k9.mailstore.FolderRepository -import com.fsck.k9.mailstore.FolderRepositoryManager import com.fsck.k9.preferences.ServerTypeConverter.fromServerSettingsType import com.fsck.k9.preferences.Settings.InvalidSettingValueException import com.fsck.k9.preferences.Settings.SettingsDescription import java.io.OutputStream import java.text.SimpleDateFormat import java.util.Calendar +import java.util.Locale import org.xmlpull.v1.XmlSerializer import timber.log.Timber @@ -24,7 +24,7 @@ class SettingsExporter( private val contentResolver: ContentResolver, private val preferences: Preferences, private val folderSettingsProvider: FolderSettingsProvider, - private val folderRepositoryManager: FolderRepositoryManager + private val folderRepository: FolderRepository ) { @Throws(SettingsImportExportException::class) fun exportToUri(includeGlobals: Boolean, accountUuids: Set, uri: Uri) { @@ -211,7 +211,6 @@ class SettingsExporter( } } - val folderRepository = folderRepositoryManager.getFolderRepository(account) writeFolderNameSettings(account, folderRepository, serializer) serializer.endTag(null, SETTINGS_ELEMENT) @@ -270,17 +269,29 @@ class SettingsExporter( folderRepository: FolderRepository, serializer: XmlSerializer ) { - fun writeFolderNameSetting(key: String, folderId: Long?, importedFolderServerId: String?) { + fun writeFolderNameSetting( + key: String, + folderId: Long?, + importedFolderServerId: String?, + writeEmptyValue: Boolean = false + ) { val folderServerId = folderId?.let { - folderRepository.getFolderServerId(folderId) + folderRepository.getFolderServerId(account, folderId) } ?: importedFolderServerId if (folderServerId != null) { writeAccountSettingIfValid(serializer, key, folderServerId, account) + } else if (writeEmptyValue) { + writeAccountSettingIfValid(serializer, key, valueString = "", account) } } - writeFolderNameSetting("autoExpandFolderName", account.autoExpandFolderId, account.importedAutoExpandFolder) + writeFolderNameSetting( + "autoExpandFolderName", + account.autoExpandFolderId, + account.importedAutoExpandFolder, + writeEmptyValue = true + ) writeFolderNameSetting("archiveFolderName", account.archiveFolderId, account.importedArchiveFolder) writeFolderNameSetting("draftsFolderName", account.draftsFolderId, account.importedDraftsFolder) writeFolderNameSetting("sentFolderName", account.sentFolderId, account.importedSentFolder) @@ -435,7 +446,7 @@ class SettingsExporter( fun generateDatedExportFileName(): String { val now = Calendar.getInstance() - val dateFormat = SimpleDateFormat("yyyy-MM-dd") + val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US) return String.format("%s_%s.%s", EXPORT_FILENAME_PREFIX, dateFormat.format(now.time), EXPORT_FILENAME_SUFFIX) } diff --git a/app/core/src/main/java/com/fsck/k9/preferences/SettingsImporter.java b/app/core/src/main/java/com/fsck/k9/preferences/SettingsImporter.java index 67a9e875f8eaeedfdcdead71c86dfd86ca3dac1b..e07208a1679e276680daff214da8ebd56df3b7c7 100644 --- a/app/core/src/main/java/com/fsck/k9/preferences/SettingsImporter.java +++ b/app/core/src/main/java/com/fsck/k9/preferences/SettingsImporter.java @@ -266,11 +266,6 @@ public class SettingsImporter { StorageEditor editor = preferences.createStorageEditor(); - String defaultAccountUuid = storage.getString("defaultAccountUuid", null); - if (defaultAccountUuid == null) { - putString(editor, "defaultAccountUuid", accountUuids.get(0)); - } - if (!editor.commit()) { throw new SettingsImportExportException("Failed to set default account"); } diff --git a/app/core/src/main/java/com/fsck/k9/provider/EmailProvider.java b/app/core/src/main/java/com/fsck/k9/provider/EmailProvider.java index f5ab06d99d1a89af8d2d0c40dd97ef986b242d0d..f24173dbe59697cba1c612956b60a91b7c7c5ae7 100644 --- a/app/core/src/main/java/com/fsck/k9/provider/EmailProvider.java +++ b/app/core/src/main/java/com/fsck/k9/provider/EmailProvider.java @@ -27,8 +27,6 @@ import com.fsck.k9.mailstore.LocalStore; import com.fsck.k9.mailstore.LocalStoreProvider; import com.fsck.k9.mailstore.LockableDatabase; import com.fsck.k9.mailstore.LockableDatabase.DbCallback; -import com.fsck.k9.mailstore.LockableDatabase.WrappedException; -import com.fsck.k9.mailstore.UnavailableStorageException; import com.fsck.k9.search.SqlQueryBuilder; @@ -277,8 +275,7 @@ public class EmailProvider extends ContentProvider { try { return database.execute(false, new DbCallback() { @Override - public Cursor doDbWork(SQLiteDatabase db) throws WrappedException, - UnavailableStorageException { + public Cursor doDbWork(SQLiteDatabase db) { String where; if (TextUtils.isEmpty(selection)) { @@ -326,8 +323,6 @@ public class EmailProvider extends ContentProvider { return cursor; } }); - } catch (UnavailableStorageException e) { - throw new RuntimeException("Storage not available", e); } catch (MessagingException e) { throw new RuntimeException("messaging exception", e); } @@ -342,8 +337,7 @@ public class EmailProvider extends ContentProvider { try { return database.execute(false, new DbCallback() { @Override - public Cursor doDbWork(SQLiteDatabase db) throws WrappedException, - UnavailableStorageException { + public Cursor doDbWork(SQLiteDatabase db) { StringBuilder query = new StringBuilder(); @@ -401,8 +395,6 @@ public class EmailProvider extends ContentProvider { return db.rawQuery(query.toString(), selectionArgs); } }); - } catch (UnavailableStorageException e) { - throw new RuntimeException("Storage not available", e); } catch (MessagingException e) { throw new RuntimeException("messaging exception", e); } @@ -469,8 +461,7 @@ public class EmailProvider extends ContentProvider { try { return database.execute(false, new DbCallback() { @Override - public Cursor doDbWork(SQLiteDatabase db) throws WrappedException, - UnavailableStorageException { + public Cursor doDbWork(SQLiteDatabase db) { StringBuilder query = new StringBuilder(); query.append("SELECT "); @@ -507,8 +498,6 @@ public class EmailProvider extends ContentProvider { return db.rawQuery(query.toString(), new String[] { threadId }); } }); - } catch (UnavailableStorageException e) { - throw new RuntimeException("Storage not available", e); } catch (MessagingException e) { throw new RuntimeException("messaging exception", e); } 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/search/LocalSearchExtensions.kt b/app/core/src/main/java/com/fsck/k9/search/LocalSearchExtensions.kt index 8e49538dac515b1df574255d2cf10c66b80ae599..d528446987b95e18cdca516aeae79c9d943a4373 100644 --- a/app/core/src/main/java/com/fsck/k9/search/LocalSearchExtensions.kt +++ b/app/core/src/main/java/com/fsck/k9/search/LocalSearchExtensions.kt @@ -5,6 +5,18 @@ package com.fsck.k9.search import com.fsck.k9.Account import com.fsck.k9.Preferences +val LocalSearch.isUnifiedInbox: Boolean + get() = id == SearchAccount.UNIFIED_INBOX + +val LocalSearch.isNewMessages: Boolean + get() = id == SearchAccount.NEW_MESSAGES + +val LocalSearch.isSingleAccount: Boolean + get() = accountUuids.size == 1 + +val LocalSearch.isSingleFolder: Boolean + get() = isSingleAccount && folderIds.size == 1 + @JvmName("getAccountsFromLocalSearch") fun LocalSearch.getAccounts(preferences: Preferences): List { val accounts = preferences.accounts diff --git a/app/core/src/main/java/com/fsck/k9/search/SearchAccount.java b/app/core/src/main/java/com/fsck/k9/search/SearchAccount.java index 53a8066d3ba822e157bb54d5e89090ef499992f7..b5ccbacb30d126529af5fed8a8546944a412af1c 100644 --- a/app/core/src/main/java/com/fsck/k9/search/SearchAccount.java +++ b/app/core/src/main/java/com/fsck/k9/search/SearchAccount.java @@ -14,6 +14,7 @@ import com.fsck.k9.search.SearchSpecification.SearchField; */ public class SearchAccount implements BaseAccount { public static final String UNIFIED_INBOX = "unified_inbox"; + public static final String NEW_MESSAGES = "new_messages"; // create the unified inbox meta account ( all accounts is default when none specified ) diff --git a/app/core/src/main/java/com/fsck/k9/search/SearchSpecification.java b/app/core/src/main/java/com/fsck/k9/search/SearchSpecification.java index 7ca698844c3278ef7d123c5b5ebfe97a1888d4dd..f97875167f59cd1e10d097a4c9ebca1f6f86a0db 100644 --- a/app/core/src/main/java/com/fsck/k9/search/SearchSpecification.java +++ b/app/core/src/main/java/com/fsck/k9/search/SearchSpecification.java @@ -68,6 +68,7 @@ public interface SearchSpecification extends Parcelable { THREAD_ID, ID, INTEGRATE, + NEW_MESSAGE, READ, FLAGGED, DISPLAY_CLASS, diff --git a/app/core/src/main/java/com/fsck/k9/search/SqlQueryBuilder.java b/app/core/src/main/java/com/fsck/k9/search/SqlQueryBuilder.java index 1b70f491d274b9a8411dab130d1bc08a5d2be653..06b14bef874ed817aae3bc7679f164a2951ea6d5 100644 --- a/app/core/src/main/java/com/fsck/k9/search/SqlQueryBuilder.java +++ b/app/core/src/main/java/com/fsck/k9/search/SqlQueryBuilder.java @@ -150,6 +150,10 @@ public class SqlQueryBuilder { columnName = "integrate"; break; } + case NEW_MESSAGE: { + columnName = "new_message"; + break; + } case READ: { columnName = "read"; break; @@ -247,6 +251,7 @@ public class SqlQueryBuilder { case FOLDER: case ID: case INTEGRATE: + case NEW_MESSAGE: case THREAD_ID: case READ: case FLAGGED: { 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 5aa195aaa0bc3a5a7bfb019c5d4cba035e214255..efc8bf620876e1df7d44a074ce983d04e54a91c0 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,17 +8,15 @@ 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.mailstore.UnavailableStorageException; -import com.fsck.k9.power.TracingPowerManager; -import com.fsck.k9.power.TracingPowerManager.TracingWakeLock; import timber.log.Timber; /** @@ -103,7 +101,7 @@ public class DatabaseUpgradeService extends Service { private int mProgress; private int mProgressEnd; - private TracingWakeLock mWakeLock; + private WakeLock mWakeLock; @Override @@ -140,8 +138,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); } @@ -195,8 +193,6 @@ public class DatabaseUpgradeService extends Service { try { // Account.getLocalStore() is blocking and will upgrade the database if necessary DI.get(LocalStoreProvider.class).getInstance(account); - } catch (UnavailableStorageException e) { - Timber.e("Database unavailable"); } catch (Exception e) { Timber.e(e, "Error while upgrading database"); } diff --git a/app/core/src/main/java/com/fsck/k9/service/StorageGoneReceiver.java b/app/core/src/main/java/com/fsck/k9/service/StorageGoneReceiver.java deleted file mode 100644 index e103fdf416c97e54f691a1bbb936be41fa3354d4..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/service/StorageGoneReceiver.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.fsck.k9.service; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import timber.log.Timber; - -import com.fsck.k9.mailstore.StorageManager; - -/** - * That BroadcastReceiver is only interested in UNMOUNT events. - * - *

- * Code was separated from {@link StorageReceiver} because we don't want that - * receiver to be statically defined in manifest. - *

- */ -public class StorageGoneReceiver extends BroadcastReceiver { - - @Override - public void onReceive(final Context context, final Intent intent) { - final String action = intent.getAction(); - final Uri uri = intent.getData(); - - if (uri == null || uri.getPath() == null) { - return; - } - - Timber.v("StorageGoneReceiver: %s", intent); - - final String path = uri.getPath(); - - if (Intent.ACTION_MEDIA_EJECT.equals(action)) { - StorageManager.getInstance(context).onBeforeUnmount(path); - } else if (Intent.ACTION_MEDIA_UNMOUNTED.equals(action)) { - StorageManager.getInstance(context).onAfterUnmount(path); - } - } - -} diff --git a/app/core/src/main/java/com/fsck/k9/service/StorageReceiver.java b/app/core/src/main/java/com/fsck/k9/service/StorageReceiver.java deleted file mode 100644 index a3a93e4715922bee4174aa30af537c63ac08de9a..0000000000000000000000000000000000000000 --- a/app/core/src/main/java/com/fsck/k9/service/StorageReceiver.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.fsck.k9.service; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import timber.log.Timber; - -import com.fsck.k9.mailstore.StorageManager; - -/** - * That BroadcastReceiver is only interested in MOUNT events. - */ -public class StorageReceiver extends BroadcastReceiver { - - @Override - public void onReceive(final Context context, final Intent intent) { - final String action = intent.getAction(); - final Uri uri = intent.getData(); - - if (uri == null || uri.getPath() == null) { - return; - } - - Timber.v("StorageReceiver: %s", intent); - - final String path = uri.getPath(); - - if (Intent.ACTION_MEDIA_MOUNTED.equals(action)) { - StorageManager.getInstance(context).onMount(path, - intent.getBooleanExtra("read-only", true)); - } - } - -} diff --git a/app/core/src/main/res/values/arrays_account_settings_values.xml b/app/core/src/main/res/values/arrays_account_settings_values.xml index 7ed44826d393de940f3b25e8da2384908648de67..c293fd9168977f3779167ebc5346f91c202d875f 100644 --- a/app/core/src/main/res/values/arrays_account_settings_values.xml +++ b/app/core/src/main/res/values/arrays_account_settings_values.xml @@ -24,6 +24,42 @@ 0xFF455A64 + + + + 0xFFFFB300 + 0xFFFB8C00 + 0xFFF4511E + 0xFFE53935 + + 0xFFC0CA33 + 0xFF7CB342 + 0xFF388E3C + 0xFF00897B + + 0xFF00ACC1 + 0xFF039BE5 + 0xFF1976D2 + 0xFF3949AB + + 0xFFE91E63 + 0xFF8E24AA + 0xFF5E35B1 + 0xFF455A64 + + + 0xFFFF0000 + 0xFF00FF00 + 0xFF0000FF + 0xFFFFFFFF + + 0xFFFFFF00 + 0xFF00FFFF + 0xFFFF00FF + 0x00000000 + + + -1 15 @@ -176,19 +212,6 @@ 5 - - 1 - 2 - 3 - 4 - 5 - 6 - 7 - 8 - 9 - 10 - - PREFIX HEADER diff --git a/app/core/src/main/res/values/arrays_general_settings_values.xml b/app/core/src/main/res/values/arrays_general_settings_values.xml index bbfe6bd2f70ae30f6cd7ff65397b71288494fa90..560eb00e8edfac15ef728c39ebdaddb1844b1ec9 100644 --- a/app/core/src/main/res/values/arrays_general_settings_values.xml +++ b/app/core/src/main/res/values/arrays_general_settings_values.xml @@ -171,12 +171,6 @@ 6 - - NEVER - WHEN_LOCKED - ALWAYS - - NEVER FOR_SINGLE_MSG 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/TestApp.kt b/app/core/src/test/java/com/fsck/k9/TestApp.kt index 21a36f3eeb480ab02c8f3dda14323dfb053a2bfe..b365bd4ddc5c2190f0740ad49585b8d54d791a4c 100644 --- a/app/core/src/test/java/com/fsck/k9/TestApp.kt +++ b/app/core/src/test/java/com/fsck/k9/TestApp.kt @@ -16,7 +16,7 @@ import org.mockito.kotlin.mock class TestApp : Application() { override fun onCreate() { - Core.earlyInit(this) + Core.earlyInit() super.onCreate() DI.start(this, coreModules + storageModule + testModule) 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..d13be3af8afb9a10c3613b3ba3b97f52f7f7c8a5 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() = "" @@ -34,8 +26,6 @@ class TestCoreResourceProvider : CoreResourceProvider { override fun replyHeader(sender: String) = "$sender wrote:" override fun replyHeader(sender: String, sentDate: String) = "On $sentDate, $sender wrote:" - override fun searchAllMessagesTitle() = "All messages" - override fun searchAllMessagesDetail() = "All messages in searchable folders" override fun searchUnifiedInboxTitle() = "Unified Inbox" override fun searchUnifiedInboxDetail() = "All messages in unified folders" 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 c7e6429b527c564c5e127063bbc9929baf0d8e1f..6525e7423d759ec5bd47e6a493175dee14be2797 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 @@ -14,11 +14,14 @@ import com.fsck.k9.K9RobolectricTest; import com.fsck.k9.Preferences; import com.fsck.k9.backend.BackendManager; import com.fsck.k9.backend.api.Backend; +import com.fsck.k9.mail.AuthType; import com.fsck.k9.mail.AuthenticationFailedException; import com.fsck.k9.mail.CertificateValidationException; +import com.fsck.k9.mail.ConnectionSecurity; import com.fsck.k9.mail.Flag; import com.fsck.k9.mail.MessageRetrievalListener; import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.ServerSettings; import com.fsck.k9.mailstore.LocalFolder; import com.fsck.k9.mailstore.LocalMessage; import com.fsck.k9.mailstore.LocalStore; @@ -28,9 +31,9 @@ import com.fsck.k9.mailstore.OutboxState; import com.fsck.k9.mailstore.OutboxStateRepository; import com.fsck.k9.mailstore.SaveMessageDataCreator; import com.fsck.k9.mailstore.SendState; -import com.fsck.k9.mailstore.UnavailableStorageException; import com.fsck.k9.notification.NotificationController; import com.fsck.k9.notification.NotificationStrategy; +import com.fsck.k9.preferences.Protocols; import com.fsck.k9.search.LocalSearch; import com.fsck.k9.search.SearchAccount; import org.jetbrains.annotations.NotNull; @@ -50,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; @@ -110,15 +113,15 @@ public class MessagingControllerTest extends K9RobolectricTest { private LocalMessage localMessageToSend1; private volatile boolean hasFetchedMessage = false; - private UnreadMessageCountProvider unreadMessageCountProvider = new UnreadMessageCountProvider() { + private MessageCountsProvider messageCountsProvider = new MessageCountsProvider() { @Override - public int getUnreadMessageCount(@NotNull SearchAccount searchAccount) { - return 0; + public MessageCounts getMessageCounts(@NotNull SearchAccount searchAccount) { + return new MessageCounts(0, 0); } @Override - public int getUnreadMessageCount(@NotNull Account account) { - return 0; + public MessageCounts getMessageCounts(@NotNull Account account) { + return new MessageCounts(0, 0); } }; @@ -135,7 +138,7 @@ public class MessagingControllerTest extends K9RobolectricTest { preferences = Preferences.getPreferences(appContext); controller = new MessagingController(appContext, notificationController, notificationStrategy, - localStoreProvider, unreadMessageCountProvider, backendManager, preferences, messageStoreManager, + localStoreProvider, messageCountsProvider, backendManager, preferences, messageStoreManager, saveMessageDataCreator, Collections.emptyList()); configureAccount(); @@ -164,13 +167,6 @@ public class MessagingControllerTest extends K9RobolectricTest { verify(localFolder).clearAllMessages(); } - @Test(expected = UnavailableAccountException.class) - public void clearFolderSynchronous_whenStorageUnavailable_shouldThrowUnavailableAccountException() throws MessagingException { - doThrow(new UnavailableStorageException("Test")).when(localFolder).open(); - - controller.clearFolderSynchronous(account, FOLDER_ID); - } - @Test public void refreshRemoteSynchronous_shouldCallBackend() throws MessagingException { controller.refreshFolderListSynchronous(account); @@ -334,7 +330,7 @@ public class MessagingControllerTest extends K9RobolectricTest { controller.sendPendingMessagesSynchronous(account); - verifyZeroInteractions(listener); + verifyNoMoreInteractions(listener); } @Test @@ -441,6 +437,10 @@ public class MessagingControllerTest extends K9RobolectricTest { account = preferences.newAccount(); accountUuid = account.getUuid(); + account.setIncomingServerSettings(new ServerSettings(Protocols.IMAP, "host", 993, + 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/MessageHelperTest.java b/app/core/src/test/java/com/fsck/k9/helper/MessageHelperTest.java index f5b7cc0eb7c1920a64d18c2a68773340fa81f804..85347a322004908c4d3df12254c5f9572aa6c7d1 100644 --- a/app/core/src/test/java/com/fsck/k9/helper/MessageHelperTest.java +++ b/app/core/src/test/java/com/fsck/k9/helper/MessageHelperTest.java @@ -94,6 +94,20 @@ public class MessageHelperTest extends RobolectricTest { assertEquals("test@testor.com", friendly.toString()); } + @Test + public void toFriendly_atPrecededByOpeningParenthesisShouldNotTriggerSpoofPrevention() { + Address address = new Address("gitlab@gitlab.example", "username (@username)"); + CharSequence friendly = MessageHelper.toFriendly(address, contacts); + assertEquals("username (@username)", friendly.toString()); + } + + @Test + public void toFriendly_nameStartingWithAtShouldNotTriggerSpoofPrevention() { + Address address = new Address("address@domain.example", "@username"); + CharSequence friendly = MessageHelper.toFriendly(address, contacts); + assertEquals("@username", friendly.toString()); + } + @Test public void toFriendly_spoofPreventionDoesntOverrideContact() { Address address = new Address("test@testor.com", "Tim Testor"); 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/K9BackendFolderTest.kt b/app/core/src/test/java/com/fsck/k9/mailstore/K9BackendFolderTest.kt index 197608f3f9146c2687b062fff645b8959eb8abfd..49bc6fc81dc1d75be1da3bca4e96f80fbcf6bb21 100644 --- a/app/core/src/test/java/com/fsck/k9/mailstore/K9BackendFolderTest.kt +++ b/app/core/src/test/java/com/fsck/k9/mailstore/K9BackendFolderTest.kt @@ -78,16 +78,6 @@ class K9BackendFolderTest : K9RobolectricTest() { assertEquals(flags, messageFlags) } - @Test - fun getLastUid() { - createMessageInBackendFolder("200") - createMessageInBackendFolder("123") - - val lastUid = backendFolder.getLastUid() - - assertEquals(200L, lastUid) - } - @Test fun saveCompleteMessage_withoutServerId_shouldThrow() { val message = createMessage(messageServerId = null) 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 6c374556af13df61736e373fa7e00ebb595b023f..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 @@ -2,6 +2,7 @@ package com.fsck.k9.mailstore; import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Date; import java.util.List; @@ -17,6 +18,7 @@ import com.fsck.k9.DI; import com.fsck.k9.K9RobolectricTest; import com.fsck.k9.TestCoreResourceProvider; import com.fsck.k9.mail.Address; +import com.fsck.k9.mail.Body; import com.fsck.k9.mail.BodyPart; import com.fsck.k9.mail.Message; import com.fsck.k9.mail.MessagingException; @@ -48,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; @@ -135,7 +137,7 @@ public class MessageViewInfoExtractorTest extends K9RobolectricTest { @Test public void testTextPlainFormatFlowed() throws MessagingException { // Create text/plain body - TextBody body = new TextBody(BODY_TEXT_FLOWED); + Body body = new BinaryMemoryBody(BODY_TEXT_FLOWED.getBytes(StandardCharsets.UTF_8), "utf-8"); // Create message MimeMessage message = new MimeMessage(); 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 new file mode 100644 index 0000000000000000000000000000000000000000..2be6075663d483c2f0daaf0a17703bfd3f99f70f --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/IdentityHeaderBuilderTest.kt @@ -0,0 +1,56 @@ +package com.fsck.k9.message + +import com.fsck.k9.Account.QuoteStyle +import com.fsck.k9.Identity +import com.fsck.k9.RobolectricTest +import com.fsck.k9.mail.internet.MimeHeaderChecker +import com.fsck.k9.mail.internet.TextBody +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +private const val IDENTITY_HEADER = "X-K9mail-Identity" + +class IdentityHeaderBuilderTest : RobolectricTest() { + @Test + fun `valid unstructured header field value`() { + val signature = "a".repeat(1000) + + val identityHeader = IdentityHeaderBuilder() + .setCursorPosition(0) + .setIdentity(createIdentity(signatureUse = true)) + .setIdentityChanged(false) + .setMessageFormat(SimpleMessageFormat.TEXT) + .setMessageReference(null) + .setQuotedHtmlContent(null) + .setQuoteStyle(QuoteStyle.PREFIX) + .setQuoteTextMode(QuotedTextMode.NONE) + .setSignature(signature) + .setSignatureChanged(true) + .setBody(TextBody("irrelevant")) + .setBodyPlain(null) + .build() + + assertThat(identityHeader.length).isGreaterThan(1000) + assertIsValidHeader(identityHeader) + } + + private fun assertIsValidHeader(identityHeader: String) { + try { + MimeHeaderChecker.checkHeader(IDENTITY_HEADER, identityHeader) + } catch (e: Exception) { + println("$IDENTITY_HEADER: $identityHeader") + throw e + } + } + + private fun createIdentity( + description: String? = null, + name: String? = null, + email: String? = null, + signature: String? = null, + signatureUse: Boolean = false, + replyTo: String? = null + ): Identity { + return Identity(description, name, email, signature, signatureUse, replyTo) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/message/IdentityHeaderParserTest.kt b/app/core/src/test/java/com/fsck/k9/message/IdentityHeaderParserTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..1d58f7c2125ec1d20b398c2f4cc614847b37f0b5 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/message/IdentityHeaderParserTest.kt @@ -0,0 +1,33 @@ +package com.fsck.k9.message + +import com.fsck.k9.RobolectricTest +import com.fsck.k9.helper.toCrLf +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class IdentityHeaderParserTest : RobolectricTest() { + @Test + fun `folded header value`() { + val input = """ + |!l=10&o=0&qs=PREFIX&f=TEXT&s=aaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&p=0&q=NONE + """.trimMargin().toCrLf() + + val result = IdentityHeaderParser.parse(input) + + assertThat(result).containsEntry(IdentityField.SIGNATURE, "a".repeat(1000)) + } +} 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 0c745e9402cf5a7c1bfa95a480c1cc5ef1865b1e..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; @@ -52,6 +52,10 @@ public class MessageBuilderTest extends RobolectricTest { private static final String TEST_ATTACHMENT_TEXT = "text data in attachment"; private static final String TEST_SUBJECT = "test_subject"; private static final Address TEST_IDENTITY_ADDRESS = new Address("test@example.org", "tester"); + private static final Address[] TEST_REPLY_TO = new Address[] { + new Address("reply-to1@example.org", "reply 1"), + new Address("reply-to2@example.org", "reply 2") + }; private static final Address[] TEST_TO = new Address[] { new Address("to1@example.org", "recip 1"), new Address("to2@example.org", "recip 2") @@ -75,6 +79,7 @@ public class MessageBuilderTest extends RobolectricTest { "BCC: bcc recip \r\n" + "Subject: test_subject\r\n" + "User-Agent: K-9 Mail for Android\r\n" + + "Reply-to: reply 1 , reply 2 \r\n" + "In-Reply-To: inreplyto\r\n" + "References: references\r\n" + "Message-ID: " + TEST_MESSAGE_ID + "\r\n" + @@ -214,6 +219,7 @@ public class MessageBuilderTest extends RobolectricTest { assertEquals("text/plain", message.getMimeType()); assertEquals(TEST_SUBJECT, message.getSubject()); assertEquals(TEST_IDENTITY_ADDRESS, message.getFrom()[0]); + assertArrayEquals(TEST_REPLY_TO, message.getReplyTo()); assertArrayEquals(TEST_TO, message.getRecipients(RecipientType.TO)); assertArrayEquals(TEST_CC, message.getRecipients(RecipientType.CC)); assertArrayEquals(TEST_BCC, message.getRecipients(RecipientType.BCC)); @@ -432,6 +438,7 @@ public class MessageBuilderTest extends RobolectricTest { .setSubject(TEST_SUBJECT) .setSentDate(SENT_DATE) .setHideTimeZone(true) + .setReplyTo(TEST_REPLY_TO) .setTo(Arrays.asList(TEST_TO)) .setCc(Arrays.asList(TEST_CC)) .setBcc(Arrays.asList(TEST_BCC)) 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.java b/app/core/src/test/java/com/fsck/k9/notification/AddNotificationResultTest.java deleted file mode 100644 index 3dbc35b5594a5c4b9faf80cb08191cf185edc319..0000000000000000000000000000000000000000 --- a/app/core/src/test/java/com/fsck/k9/notification/AddNotificationResultTest.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.fsck.k9.notification; - - -import org.junit.Before; -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - - -public class AddNotificationResultTest { - private static final int NOTIFICATION_ID = 23; - - - private NotificationHolder notificationHolder; - - - @Before - public void setUp() throws Exception { - notificationHolder = new NotificationHolder(NOTIFICATION_ID, null); - } - - @Test - public void newNotification_shouldCancelNotification_shouldReturnFalse() throws Exception { - AddNotificationResult result = AddNotificationResult.newNotification(notificationHolder); - - assertFalse(result.shouldCancelNotification()); - } - - @Test(expected = IllegalStateException.class) - public void newNotification_getNotificationId_shouldReturnNotificationId() throws Exception { - AddNotificationResult result = AddNotificationResult.newNotification(notificationHolder); - - result.getNotificationId(); - } - - @Test - public void replaceNotification_shouldCancelNotification_shouldReturnTrue() throws Exception { - AddNotificationResult result = AddNotificationResult.replaceNotification(notificationHolder); - - assertTrue(result.shouldCancelNotification()); - } - - @Test - public void replaceNotification_getNotificationId_shouldReturnNotificationId() throws Exception { - AddNotificationResult result = AddNotificationResult.replaceNotification(notificationHolder); - - assertEquals(NOTIFICATION_ID, result.getNotificationId()); - } - - @Test - public void getNotificationHolder_shouldReturnNotificationHolder() throws Exception { - AddNotificationResult result = AddNotificationResult.replaceNotification(notificationHolder); - - assertEquals(notificationHolder, result.getNotificationHolder()); - } -} 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..af796ff184e9a011110b80a515c898c94e920a2f --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/AuthenticationErrorNotificationControllerTest.kt @@ -0,0 +1,121 @@ +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 +import org.mockito.kotlin.never + +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 lockScreenNotification = mock() + private val notificationManager = mock() + private val builder = createFakeNotificationBuilder(notification) + private val lockScreenNotificationBuilder = createFakeNotificationBuilder(lockScreenNotification) + private val notificationHelper = createFakeNotificationHelper( + notificationManager, + builder, + lockScreenNotificationBuilder + ) + 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).setPublicVersion(lockScreenNotification) + verify(lockScreenNotificationBuilder).setContentTitle("Authentication failed") + verify(lockScreenNotificationBuilder, never()).setContentText(any()) + verify(lockScreenNotificationBuilder, never()).setTicker(any()) + } + + private fun createFakeNotificationBuilder(notification: Notification): NotificationCompat.Builder { + return mockBuilder { + on { build() } doReturn notification + } + } + + private fun createFakeNotificationHelper( + notificationManager: NotificationManagerCompat, + notificationBuilder: NotificationCompat.Builder, + lockScreenNotificationBuilder: NotificationCompat.Builder + ): NotificationHelper { + return mock { + on { getContext() } doReturn ApplicationProvider.getApplicationContext() + on { getNotificationManager() } doReturn notificationManager + on { createNotificationBuilder(any(), any()) }.doReturn(notificationBuilder, lockScreenNotificationBuilder) + } + } + + 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/AuthenticationErrorNotificationsTest.java b/app/core/src/test/java/com/fsck/k9/notification/AuthenticationErrorNotificationsTest.java deleted file mode 100644 index 558b6983f50cb9f613836699d9169d2b0d85c807..0000000000000000000000000000000000000000 --- a/app/core/src/test/java/com/fsck/k9/notification/AuthenticationErrorNotificationsTest.java +++ /dev/null @@ -1,148 +0,0 @@ -package com.fsck.k9.notification; - - -import android.app.Notification; -import android.app.PendingIntent; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationCompat.Builder; -import androidx.core.app.NotificationManagerCompat; - -import com.fsck.k9.Account; -import com.fsck.k9.testing.MockHelper; -import com.fsck.k9.RobolectricTest; -import org.junit.Before; -import org.junit.Test; -import org.robolectric.RuntimeEnvironment; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - - -public class AuthenticationErrorNotificationsTest extends RobolectricTest { - private static final boolean INCOMING = true; - private static final boolean OUTGOING = false; - private static final int ACCOUNT_NUMBER = 1; - private static final String ACCOUNT_NAME = "TestAccount"; - - - private NotificationResourceProvider resourceProvider = new TestNotificationResourceProvider(); - private Notification notification; - private NotificationManagerCompat notificationManager; - private NotificationCompat.Builder builder; - private NotificationHelper notificationHelper; - private Account account; - private AuthenticationErrorNotifications authenticationErrorNotifications; - private PendingIntent contentIntent; - - - @Before - public void setUp() throws Exception { - notification = createFakeNotification(); - notificationManager = createFakeNotificationManager(); - builder = createFakeNotificationBuilder(notification); - notificationHelper = createFakeNotificationHelper(notificationManager, builder); - account = createFakeAccount(); - contentIntent = createFakeContentIntent(); - - authenticationErrorNotifications = new TestAuthenticationErrorNotifications(); - } - - @Test - public void showAuthenticationErrorNotification_withIncomingServer_shouldCreateNotification() throws Exception { - int notificationId = NotificationIds.getAuthenticationErrorNotificationId(account, INCOMING); - - authenticationErrorNotifications.showAuthenticationErrorNotification(account, INCOMING); - - verify(notificationManager).notify(notificationId, notification); - assertAuthenticationErrorNotificationContents(); - } - - @Test - public void clearAuthenticationErrorNotification_withIncomingServer_shouldCancelNotification() throws Exception { - int notificationId = NotificationIds.getAuthenticationErrorNotificationId(account, INCOMING); - - authenticationErrorNotifications.clearAuthenticationErrorNotification(account, INCOMING); - - verify(notificationManager).cancel(notificationId); - } - - @Test - public void showAuthenticationErrorNotification_withOutgoingServer_shouldCreateNotification() throws Exception { - int notificationId = NotificationIds.getAuthenticationErrorNotificationId(account, OUTGOING); - - authenticationErrorNotifications.showAuthenticationErrorNotification(account, OUTGOING); - - verify(notificationManager).notify(notificationId, notification); - assertAuthenticationErrorNotificationContents(); - } - - @Test - public void clearAuthenticationErrorNotification_withOutgoingServer_shouldCancelNotification() throws Exception { - int notificationId = NotificationIds.getAuthenticationErrorNotificationId(account, OUTGOING); - - authenticationErrorNotifications.clearAuthenticationErrorNotification(account, OUTGOING); - - verify(notificationManager).cancel(notificationId); - } - - private void assertAuthenticationErrorNotificationContents() { - verify(builder).setSmallIcon(resourceProvider.getIconWarning()); - 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 Notification createFakeNotification() { - return mock(Notification.class); - } - - private NotificationManagerCompat createFakeNotificationManager() { - return mock(NotificationManagerCompat.class); - } - - private Builder createFakeNotificationBuilder(Notification notification) { - Builder builder = MockHelper.mockBuilder(Builder.class); - when(builder.build()).thenReturn(notification); - return builder; - } - - private NotificationHelper createFakeNotificationHelper(NotificationManagerCompat notificationManager, - NotificationCompat.Builder builder) { - NotificationHelper notificationHelper = mock(NotificationHelper.class); - when(notificationHelper.getContext()).thenReturn(RuntimeEnvironment.application); - when(notificationHelper.getNotificationManager()).thenReturn(notificationManager); - when(notificationHelper.createNotificationBuilder(any(Account.class), - any(NotificationChannelManager.ChannelType.class))) - .thenReturn(builder); - - return notificationHelper; - } - - private Account createFakeAccount() { - Account account = mock(Account.class); - when(account.getAccountNumber()).thenReturn(ACCOUNT_NUMBER); - when(account.getDescription()).thenReturn(ACCOUNT_NAME); - - return account; - } - - private PendingIntent createFakeContentIntent() { - return mock(PendingIntent.class); - } - - - class TestAuthenticationErrorNotifications extends AuthenticationErrorNotifications { - public TestAuthenticationErrorNotifications() { - super(notificationHelper, mock(NotificationActionCreator.class), resourceProvider); - } - - @Override - PendingIntent createContentIntent(Account account, boolean incoming) { - 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..5b0cf007a33b8f46d9b08b749aff18f4bef83565 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/BaseNotificationDataCreatorTest.kt @@ -0,0 +1,198 @@ +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 One, Sender Two, Sender Three") + } + + @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.isVibrateEnabled = true + 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 activeNotifications = senders.mapIndexed { index, sender -> + NotificationHolder( + notificationId = index, + timestamp = 0L, + content = NotificationContent( + messageReference = mock(), + sender = sender, + preview = "irrelevant", + summary = "irrelevant", + subject = "irrelevant" + ) + ) + } + return NotificationData(account, activeNotifications, inactiveNotifications = emptyList()) + } + + private fun createAccount(): Account { + return Account("00000000-0000-4000-0000-000000000000").apply { + description = "account name" + identities = listOf(Identity()) + } + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/BaseNotificationsTest.java b/app/core/src/test/java/com/fsck/k9/notification/BaseNotificationsTest.java deleted file mode 100644 index 59624902ff407ade31551cfb9af4ef25ca6e9342..0000000000000000000000000000000000000000 --- a/app/core/src/test/java/com/fsck/k9/notification/BaseNotificationsTest.java +++ /dev/null @@ -1,144 +0,0 @@ -package com.fsck.k9.notification; - - -import androidx.core.app.NotificationCompat.BigTextStyle; -import androidx.core.app.NotificationCompat.Builder; - -import com.fsck.k9.Account; -import com.fsck.k9.K9; -import com.fsck.k9.K9.NotificationQuickDelete; -import com.fsck.k9.testing.MockHelper; -import org.junit.Before; -import org.junit.Test; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.mockito.Matchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - - -public class BaseNotificationsTest { - private static final int ACCOUNT_COLOR = 0xAABBCC; - private static final String ACCOUNT_NAME = "AccountName"; - private static final int ACCOUNT_NUMBER = 2; - private static final String NOTIFICATION_SUMMARY = "Summary"; - private static final String SENDER = "MessageSender"; - private static final String SUBJECT = "Subject"; - private static final String NOTIFICATION_PREVIEW = "Preview"; - - - private NotificationResourceProvider resourceProvider = new TestNotificationResourceProvider(); - private TestNotifications notifications; - - - @Before - public void setUp() throws Exception { - notifications = createTestNotifications(); - } - - @Test - public void testCreateAndInitializeNotificationBuilder() throws Exception { - Account account = createFakeAccount(); - - Builder builder = notifications.createAndInitializeNotificationBuilder(account); - - verify(builder).setSmallIcon(resourceProvider.getIconNewMail()); - verify(builder).setColor(ACCOUNT_COLOR); - verify(builder).setAutoCancel(true); - } - - @Test - public void testIsDeleteActionEnabled_NotificationQuickDelete_ALWAYS() throws Exception { - K9.setNotificationQuickDeleteBehaviour(NotificationQuickDelete.ALWAYS); - - boolean result = notifications.isDeleteActionEnabled(); - - assertTrue(result); - } - - @Test - public void testIsDeleteActionEnabled_NotificationQuickDelete_FOR_SINGLE_MSG() throws Exception { - K9.setNotificationQuickDeleteBehaviour(NotificationQuickDelete.FOR_SINGLE_MSG); - - boolean result = notifications.isDeleteActionEnabled(); - - assertTrue(result); - } - - @Test - public void testIsDeleteActionEnabled_NotificationQuickDelete_NEVER() throws Exception { - K9.setNotificationQuickDeleteBehaviour(NotificationQuickDelete.NEVER); - - boolean result = notifications.isDeleteActionEnabled(); - - assertFalse(result); - } - - @Test - public void testCreateBigTextStyleNotification() throws Exception { - Account account = createFakeAccount(); - int notificationId = 23; - NotificationHolder holder = createNotificationHolder(notificationId); - - Builder builder = notifications.createBigTextStyleNotification(account, holder, notificationId); - - verify(builder).setTicker(NOTIFICATION_SUMMARY); - verify(builder).setGroup("newMailNotifications-" + ACCOUNT_NUMBER); - verify(builder).setContentTitle(SENDER); - verify(builder).setContentText(SUBJECT); - verify(builder).setSubText(ACCOUNT_NAME); - - BigTextStyle bigTextStyle = notifications.bigTextStyle; - verify(bigTextStyle).bigText(NOTIFICATION_PREVIEW); - - verify(builder).setStyle(bigTextStyle); - } - - private NotificationHolder createNotificationHolder(int notificationId) { - NotificationContent content = new NotificationContent(null, SENDER, SUBJECT, NOTIFICATION_PREVIEW, - NOTIFICATION_SUMMARY, false); - return new NotificationHolder(notificationId, content); - } - - private TestNotifications createTestNotifications() { - NotificationHelper notificationHelper = createFakeNotificationHelper(); - NotificationActionCreator actionCreator = mock(NotificationActionCreator.class); - - return new TestNotifications(notificationHelper, actionCreator, resourceProvider); - } - - private NotificationHelper createFakeNotificationHelper() { - Builder builder = MockHelper.mockBuilder(Builder.class); - NotificationHelper notificationHelper = mock(NotificationHelper.class); - when(notificationHelper.createNotificationBuilder(any(Account.class), any(NotificationChannelManager - .ChannelType.class))).thenReturn(builder); - when(notificationHelper.getAccountName(any(Account.class))).thenReturn(ACCOUNT_NAME); - return notificationHelper; - } - - private Account createFakeAccount() { - Account account = mock(Account.class); - when(account.getAccountNumber()).thenReturn(ACCOUNT_NUMBER); - when(account.getChipColor()).thenReturn(ACCOUNT_COLOR); - return account; - } - - - static class TestNotifications extends BaseNotifications { - - BigTextStyle bigTextStyle; - - protected TestNotifications(NotificationHelper notificationHelper, NotificationActionCreator actionCreator, - NotificationResourceProvider resourceProvider) { - super(notificationHelper, actionCreator, resourceProvider); - bigTextStyle = mock(BigTextStyle.class); - } - - @Override - protected BigTextStyle createBigTextStyle(Builder builder) { - return bigTextStyle; - } - } -} 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..f853d1fa68edffd5978cf8e4f8e04209ce79ea7d --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/CertificateErrorNotificationControllerTest.kt @@ -0,0 +1,122 @@ +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 +import org.mockito.kotlin.never + +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 lockScreenNotification = mock() + private val notificationManager = mock() + private val builder = createFakeNotificationBuilder(notification) + private val lockScreenNotificationBuilder = createFakeNotificationBuilder(lockScreenNotification) + private val notificationHelper = createFakeNotificationHelper( + notificationManager, + builder, + lockScreenNotificationBuilder + ) + 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).setPublicVersion(lockScreenNotification) + verify(lockScreenNotificationBuilder).setContentTitle("Certificate error") + verify(lockScreenNotificationBuilder, never()).setContentText(any()) + verify(lockScreenNotificationBuilder, never()).setTicker(any()) + } + + private fun createFakeNotificationBuilder(notification: Notification): NotificationCompat.Builder { + return mockBuilder { + on { build() } doReturn notification + } + } + + private fun createFakeNotificationHelper( + notificationManager: NotificationManagerCompat, + notificationBuilder: NotificationCompat.Builder, + lockScreenNotificationBuilder: NotificationCompat.Builder + ): NotificationHelper { + return mock { + on { getContext() } doReturn ApplicationProvider.getApplicationContext() + on { getNotificationManager() } doReturn notificationManager + on { createNotificationBuilder(any(), any()) }.doReturn(notificationBuilder, lockScreenNotificationBuilder) + } + } + + 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/CertificateErrorNotificationsTest.java b/app/core/src/test/java/com/fsck/k9/notification/CertificateErrorNotificationsTest.java deleted file mode 100644 index c739b9395ff60c2765803b35092099eaf1097754..0000000000000000000000000000000000000000 --- a/app/core/src/test/java/com/fsck/k9/notification/CertificateErrorNotificationsTest.java +++ /dev/null @@ -1,149 +0,0 @@ -package com.fsck.k9.notification; - - -import android.app.Notification; -import android.app.PendingIntent; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationCompat.Builder; -import androidx.core.app.NotificationManagerCompat; - -import com.fsck.k9.Account; -import com.fsck.k9.testing.MockHelper; -import com.fsck.k9.RobolectricTest; -import org.junit.Before; -import org.junit.Test; -import org.robolectric.RuntimeEnvironment; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - - -public class CertificateErrorNotificationsTest extends RobolectricTest { - private static final boolean INCOMING = true; - private static final boolean OUTGOING = false; - private static final int ACCOUNT_NUMBER = 1; - private static final String ACCOUNT_NAME = "TestAccount"; - - - private NotificationResourceProvider resourceProvider = new TestNotificationResourceProvider(); - private Notification notification; - private NotificationManagerCompat notificationManager; - private NotificationCompat.Builder builder; - private NotificationHelper notificationHelper; - private Account account; - private CertificateErrorNotifications certificateErrorNotifications; - private PendingIntent contentIntent; - - - @Before - public void setUp() throws Exception { - notification = createFakeNotification(); - notificationManager = createFakeNotificationManager(); - builder = createFakeNotificationBuilder(notification); - notificationHelper = createFakeNotificationHelper(notificationManager, builder); - account = createFakeAccount(); - contentIntent = createFakeContentIntent(); - - certificateErrorNotifications = new TestCertificateErrorNotifications(); - } - - @Test - public void testShowCertificateErrorNotificationForIncomingServer() throws Exception { - int notificationId = NotificationIds.getCertificateErrorNotificationId(account, INCOMING); - - certificateErrorNotifications.showCertificateErrorNotification(account, INCOMING); - - verify(notificationManager).notify(notificationId, notification); - assertCertificateErrorNotificationContents(); - } - - @Test - public void testClearCertificateErrorNotificationsForIncomingServer() throws Exception { - int notificationId = NotificationIds.getCertificateErrorNotificationId(account, INCOMING); - - certificateErrorNotifications.clearCertificateErrorNotifications(account, INCOMING); - - verify(notificationManager).cancel(notificationId); - } - - @Test - public void testShowCertificateErrorNotificationForOutgoingServer() throws Exception { - int notificationId = NotificationIds.getCertificateErrorNotificationId(account, OUTGOING); - - certificateErrorNotifications.showCertificateErrorNotification(account, OUTGOING); - - verify(notificationManager).notify(notificationId, notification); - assertCertificateErrorNotificationContents(); - } - - @Test - public void testClearCertificateErrorNotificationsForOutgoingServer() throws Exception { - int notificationId = NotificationIds.getCertificateErrorNotificationId(account, OUTGOING); - - certificateErrorNotifications.clearCertificateErrorNotifications(account, OUTGOING); - - verify(notificationManager).cancel(notificationId); - } - - private void assertCertificateErrorNotificationContents() { - verify(builder).setSmallIcon(resourceProvider.getIconWarning()); - 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 Notification createFakeNotification() { - return mock(Notification.class); - } - - private NotificationManagerCompat createFakeNotificationManager() { - return mock(NotificationManagerCompat.class); - } - - private Builder createFakeNotificationBuilder(Notification notification) { - Builder builder = MockHelper.mockBuilder(Builder.class); - when(builder.build()).thenReturn(notification); - return builder; - } - - private NotificationHelper createFakeNotificationHelper(NotificationManagerCompat notificationManager, - NotificationCompat.Builder builder) { - NotificationHelper notificationHelper = mock(NotificationHelper.class); - when(notificationHelper.getContext()).thenReturn(RuntimeEnvironment.application); - when(notificationHelper.getNotificationManager()).thenReturn(notificationManager); - when(notificationHelper.createNotificationBuilder(any(Account.class), - any(NotificationChannelManager.ChannelType.class))) - .thenReturn(builder); - - return notificationHelper; - } - - private Account createFakeAccount() { - Account account = mock(Account.class); - when(account.getAccountNumber()).thenReturn(ACCOUNT_NUMBER); - when(account.getDescription()).thenReturn(ACCOUNT_NAME); - when(account.getUuid()).thenReturn("test-uuid"); - - return account; - } - - private PendingIntent createFakeContentIntent() { - return mock(PendingIntent.class); - } - - - class TestCertificateErrorNotifications extends CertificateErrorNotifications { - public TestCertificateErrorNotifications() { - super(notificationHelper, mock(NotificationActionCreator.class), resourceProvider); - } - - @Override - PendingIntent createContentIntent(Account account, boolean incoming) { - return contentIntent; - } - } -} diff --git a/app/core/src/test/java/com/fsck/k9/notification/DeviceNotificationsTest.java b/app/core/src/test/java/com/fsck/k9/notification/DeviceNotificationsTest.java deleted file mode 100644 index f0f0fb185ed29b69f52c61a64bdfc9b2b69cdc8d..0000000000000000000000000000000000000000 --- a/app/core/src/test/java/com/fsck/k9/notification/DeviceNotificationsTest.java +++ /dev/null @@ -1,272 +0,0 @@ -package com.fsck.k9.notification; - - -import java.util.Arrays; -import java.util.List; - -import android.app.Application; -import android.app.Notification; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationCompat.BigTextStyle; -import androidx.core.app.NotificationCompat.Builder; -import androidx.core.app.NotificationCompat.InboxStyle; - -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.NotificationSetting; -import com.fsck.k9.RobolectricTest; -import org.junit.Before; -import org.junit.Test; -import org.mockito.invocation.InvocationOnMock; -import org.mockito.stubbing.Answer; -import org.robolectric.RuntimeEnvironment; - -import static com.fsck.k9.testing.MockHelper.mockBuilder; -import static org.junit.Assert.assertEquals; -import static org.mockito.Matchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - - -public class DeviceNotificationsTest extends RobolectricTest { - private static final int UNREAD_MESSAGE_COUNT = 42; - private static final int NEW_MESSAGE_COUNT = 2; - private static final String ACCOUNT_NAME = "accountName"; - private static final int ACCOUNT_NUMBER = 3; - private static final int ACCOUNT_COLOR = 0xABCDEF; - private static final String SUMMARY = "summary"; - private static final String PREVIEW = "preview"; - private static final String SUBJECT = "subject"; - private static final String SENDER = "sender"; - private static final String SUMMARY_2 = "summary2"; - private static final String PREVIEW_2 = "preview2"; - private static final String SUBJECT_2 = "subject2"; - private static final String SENDER_2 = "sender2"; - private static final int NOTIFICATION_ID = 23; - private static final Notification FAKE_NOTIFICATION = mock(Notification.class); - - - private NotificationResourceProvider resourceProvider = new TestNotificationResourceProvider(); - private Account account; - private NotificationData notificationData; - private TestDeviceNotifications notifications; - private Builder builder; - private Builder builder2 = mockBuilder(Builder.class); - private LockScreenNotification lockScreenNotification; - - - @Before - public void setUp() throws Exception { - account = createFakeAccount(); - notificationData = createFakeNotificationData(account); - - builder = createFakeNotificationBuilder(); - lockScreenNotification = mock(LockScreenNotification.class); - notifications = createDeviceNotifications(builder, lockScreenNotification); - } - - @Test - public void buildSummaryNotification_withPrivacyModeActive() throws Exception { - K9.setNotificationHideSubject(NotificationHideSubject.ALWAYS); - - Notification result = notifications.buildSummaryNotification(account, notificationData, false); - - verify(builder).setSmallIcon(resourceProvider.getIconNewMail()); - verify(builder).setColor(ACCOUNT_COLOR); - verify(builder).setAutoCancel(true); - verify(builder).setNumber(UNREAD_MESSAGE_COUNT); - verify(builder).setTicker("New mail"); - verify(builder).setContentText("New mail"); - verify(builder).setContentTitle(UNREAD_MESSAGE_COUNT + " Unread (" + ACCOUNT_NAME + ")"); - verify(lockScreenNotification).configureLockScreenNotification(builder, notificationData); - assertEquals(FAKE_NOTIFICATION, result); - } - - @Test - public void buildSummaryNotification_withSingleMessageNotification() throws Exception { - K9.setNotificationHideSubject(NotificationHideSubject.NEVER); - K9.setNotificationQuickDeleteBehaviour(NotificationQuickDelete.ALWAYS); - when(notificationData.isSingleMessageNotification()).thenReturn(true); - - Notification result = notifications.buildSummaryNotification(account, notificationData, false); - - verify(builder).setSmallIcon(resourceProvider.getIconNewMail()); - verify(builder).setColor(ACCOUNT_COLOR); - verify(builder).setAutoCancel(true); - verify(builder).setTicker(SUMMARY); - verify(builder).setContentText(SUBJECT); - verify(builder).setContentTitle(SENDER); - verify(builder).setStyle(notifications.bigTextStyle); - verify(notifications.bigTextStyle).bigText(PREVIEW); - verify(builder).addAction(resourceProvider.getIconReply(), "Reply", null); - verify(builder).addAction(resourceProvider.getIconMarkAsRead(), "Mark Read", null); - verify(builder).addAction(resourceProvider.getIconDelete(), "Delete", null); - verify(lockScreenNotification).configureLockScreenNotification(builder, notificationData); - assertEquals(FAKE_NOTIFICATION, result); - } - - @Test - public void buildSummaryNotification_withMultiMessageNotification() throws Exception { - K9.setNotificationHideSubject(NotificationHideSubject.NEVER); - K9.setNotificationQuickDeleteBehaviour(NotificationQuickDelete.ALWAYS); - when(notificationData.isSingleMessageNotification()).thenReturn(false); - when(notificationData.containsStarredMessages()).thenReturn(true); - - Notification result = notifications.buildSummaryNotification(account, notificationData, false); - - verify(builder).setSmallIcon(resourceProvider.getIconNewMail()); - verify(builder).setColor(ACCOUNT_COLOR); - verify(builder).setAutoCancel(true); - verify(builder).setTicker(SUMMARY); - verify(builder).setContentTitle(NEW_MESSAGE_COUNT + " new messages"); - verify(builder).setSubText(ACCOUNT_NAME); - verify(builder).setGroup("newMailNotifications-" + ACCOUNT_NUMBER); - verify(builder).setGroupSummary(true); - verify(builder).setPriority(NotificationCompat.PRIORITY_HIGH); - verify(builder).setStyle(notifications.inboxStyle); - verify(notifications.inboxStyle).setBigContentTitle(NEW_MESSAGE_COUNT + " new messages"); - verify(notifications.inboxStyle).setSummaryText(ACCOUNT_NAME); - verify(notifications.inboxStyle).addLine(SUMMARY); - verify(notifications.inboxStyle).addLine(SUMMARY_2); - verify(builder).addAction(resourceProvider.getIconMarkAsRead(), "Mark Read", null); - verify(builder).addAction(resourceProvider.getIconDelete(), "Delete", null); - verify(lockScreenNotification).configureLockScreenNotification(builder, notificationData); - assertEquals(FAKE_NOTIFICATION, result); - } - - @Test - public void buildSummaryNotification_withAdditionalMessages() throws Exception { - K9.setNotificationHideSubject(NotificationHideSubject.NEVER); - K9.setNotificationQuickDeleteBehaviour(NotificationQuickDelete.ALWAYS); - when(notificationData.isSingleMessageNotification()).thenReturn(false); - when(notificationData.hasSummaryOverflowMessages()).thenReturn(true); - when(notificationData.getSummaryOverflowMessagesCount()).thenReturn(23); - - notifications.buildSummaryNotification(account, notificationData, false); - - verify(notifications.inboxStyle).setSummaryText("+ 23 more on " + ACCOUNT_NAME); - } - - @Test - public void buildSummaryNotification_withoutDeleteAllAction() throws Exception { - K9.setNotificationHideSubject(NotificationHideSubject.NEVER); - K9.setNotificationQuickDeleteBehaviour(NotificationQuickDelete.NEVER); - when(notificationData.isSingleMessageNotification()).thenReturn(false); - - notifications.buildSummaryNotification(account, notificationData, false); - - verify(builder, never()).addAction(resourceProvider.getIconDelete(), "Delete", null); - } - - @Test - public void buildSummaryNotification_withoutDeleteAction() throws Exception { - K9.setNotificationHideSubject(NotificationHideSubject.NEVER); - K9.setNotificationQuickDeleteBehaviour(NotificationQuickDelete.NEVER); - when(notificationData.isSingleMessageNotification()).thenReturn(true); - - notifications.buildSummaryNotification(account, notificationData, false); - - verify(builder, never()).addAction(resourceProvider.getIconDelete(), "Delete", null); - } - - private Builder createFakeNotificationBuilder() { - Builder builder = mockBuilder(Builder.class); - when(builder.build()).thenReturn(FAKE_NOTIFICATION); - return builder; - } - - private Account createFakeAccount() { - Account account = mock(Account.class); - - when(account.getChipColor()).thenReturn(ACCOUNT_COLOR); - when(account.getAccountNumber()).thenReturn(ACCOUNT_NUMBER); - - NotificationSetting notificationSetting = mock(NotificationSetting.class); - when(account.getNotificationSetting()).thenReturn(notificationSetting); - - return account; - } - - private NotificationData createFakeNotificationData(Account account) { - NotificationData notificationData = mock(NotificationData.class); - when(notificationData.getUnreadMessageCount()).thenReturn(UNREAD_MESSAGE_COUNT); - when(notificationData.getNewMessagesCount()).thenReturn(NEW_MESSAGE_COUNT); - when(notificationData.getAccount()).thenReturn(account); - - NotificationContent content = new NotificationContent(null, SENDER, SUBJECT, PREVIEW, SUMMARY, false); - NotificationContent content2 = new NotificationContent(null, SENDER_2, SUBJECT_2, PREVIEW_2, SUMMARY_2, true); - List contents = Arrays.asList(content, content2); - when(notificationData.getContentForSummaryNotification()).thenReturn(contents); - - NotificationHolder holder = new NotificationHolder(NOTIFICATION_ID, content); - when(notificationData.getHolderForLatestNotification()).thenReturn(holder); - - return notificationData; - } - - private TestDeviceNotifications createDeviceNotifications(Builder builder, - LockScreenNotification lockScreenNotification) { - NotificationHelper notificationHelper = createFakeNotificationHelper(builder); - NotificationActionCreator actionCreator = mock(NotificationActionCreator.class); - WearNotifications wearNotifications = mock(WearNotifications.class); - - return new TestDeviceNotifications(notificationHelper, actionCreator, lockScreenNotification, - wearNotifications, resourceProvider); - } - - private NotificationHelper createFakeNotificationHelper(final Builder builder) { - Application context = RuntimeEnvironment.application; - - NotificationHelper notificationHelper = mock(NotificationHelper.class); - when(notificationHelper.getContext()).thenReturn(context); - when(notificationHelper.getAccountName(any(Account.class))).thenReturn(ACCOUNT_NAME); - when(notificationHelper.createNotificationBuilder(any(Account.class), any(NotificationChannelManager - .ChannelType.class))).thenAnswer(new Answer() { - private int invocationCount = 0; - - @Override - public Builder answer(InvocationOnMock invocation) throws Throwable { - invocationCount++; - switch (invocationCount) { - case 1: { - return builder; - } - case 2: { - return builder2; - } - } - - throw new AssertionError("createNotificationBuilder() invoked more than twice"); - } - }); - - return notificationHelper; - } - - - static class TestDeviceNotifications extends DeviceNotifications { - BigTextStyle bigTextStyle = mockBuilder(BigTextStyle.class); - InboxStyle inboxStyle = mockBuilder(InboxStyle.class); - - - TestDeviceNotifications(NotificationHelper notificationHelper, NotificationActionCreator actionCreator, - LockScreenNotification lockScreenNotification, WearNotifications wearNotifications, - NotificationResourceProvider resourceProvider) { - super(notificationHelper, actionCreator, lockScreenNotification, wearNotifications, resourceProvider); - } - - @Override - protected BigTextStyle createBigTextStyle(Builder builder) { - return bigTextStyle; - } - - @Override - protected InboxStyle createInboxStyle(Builder builder) { - return inboxStyle; - } - } -} 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/LockScreenNotificationTest.java b/app/core/src/test/java/com/fsck/k9/notification/LockScreenNotificationTest.java deleted file mode 100644 index 8de5dc4c6d0b5930f7a03e2f704bd3ec675765e3..0000000000000000000000000000000000000000 --- a/app/core/src/test/java/com/fsck/k9/notification/LockScreenNotificationTest.java +++ /dev/null @@ -1,187 +0,0 @@ -package com.fsck.k9.notification; - - -import java.util.Arrays; - -import android.app.Notification; -import android.content.Context; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationCompat.Builder; - -import com.fsck.k9.Account; -import com.fsck.k9.K9; -import com.fsck.k9.K9.LockScreenNotificationVisibility; -import com.fsck.k9.testing.MockHelper; -import com.fsck.k9.RobolectricTest; -import org.junit.Before; -import org.junit.Test; -import org.robolectric.RuntimeEnvironment; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; -import static org.mockito.Matchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - - -public class LockScreenNotificationTest extends RobolectricTest { - private static final String ACCOUNT_NAME = "Hugo"; - private static final int NEW_MESSAGE_COUNT = 3; - private static final int UNREAD_MESSAGE_COUNT = 4; - - private NotificationResourceProvider resourceProvider = new TestNotificationResourceProvider(); - private Builder builder; - private Builder publicBuilder; - private LockScreenNotification lockScreenNotification; - private NotificationData notificationData; - - - @Before - public void setUp() throws Exception { - Context context = RuntimeEnvironment.application; - builder = createFakeNotificationBuilder(); - publicBuilder = createFakeNotificationBuilder(); - NotificationHelper notificationHelper = createFakeNotificationHelper(context, publicBuilder); - Account account = createFakeAccount(); - notificationData = createFakeNotificationData(account); - lockScreenNotification = new LockScreenNotification(notificationHelper, resourceProvider); - } - - @Test - public void configureLockScreenNotification_NOTHING() throws Exception { - K9.setLockScreenNotificationVisibility(LockScreenNotificationVisibility.NOTHING); - - lockScreenNotification.configureLockScreenNotification(builder, notificationData); - - verify(builder).setVisibility(NotificationCompat.VISIBILITY_SECRET); - } - - @Test - public void configureLockScreenNotification_APP_NAME() throws Exception { - K9.setLockScreenNotificationVisibility(LockScreenNotificationVisibility.APP_NAME); - - lockScreenNotification.configureLockScreenNotification(builder, notificationData); - - verify(builder).setVisibility(NotificationCompat.VISIBILITY_PRIVATE); - } - - @Test - public void configureLockScreenNotification_EVERYTHING() throws Exception { - K9.setLockScreenNotificationVisibility(LockScreenNotificationVisibility.EVERYTHING); - - lockScreenNotification.configureLockScreenNotification(builder, notificationData); - - verify(builder).setVisibility(NotificationCompat.VISIBILITY_PUBLIC); - } - - @Test - public void configureLockScreenNotification_SENDERS_withSingleMessage() throws Exception { - K9.setLockScreenNotificationVisibility(LockScreenNotificationVisibility.SENDERS); - String senderName = "alice@example.com"; - NotificationContent content = createNotificationContent(senderName); - NotificationHolder holder = new NotificationHolder(42, content); - when(notificationData.getNewMessagesCount()).thenReturn(1); - when(notificationData.getUnreadMessageCount()).thenReturn(1); - when(notificationData.getHolderForLatestNotification()).thenReturn(holder); - - lockScreenNotification.configureLockScreenNotification(builder, notificationData); - - verify(publicBuilder).setSmallIcon(resourceProvider.getIconNewMail()); - verify(publicBuilder).setNumber(1); - verify(publicBuilder).setContentTitle("1 new message"); - verify(publicBuilder).setContentText(senderName); - verify(builder).setPublicVersion(publicBuilder.build()); - } - - @Test - public void configureLockScreenNotification_SENDERS_withMultipleMessages() throws Exception { - K9.setLockScreenNotificationVisibility(LockScreenNotificationVisibility.SENDERS); - NotificationContent content1 = createNotificationContent("alice@example.com"); - NotificationContent content2 = createNotificationContent("Bob "); - NotificationContent content3 = createNotificationContent("\"Peter Lustig\" "); - when(notificationData.getNewMessagesCount()).thenReturn(NEW_MESSAGE_COUNT); - when(notificationData.getUnreadMessageCount()).thenReturn(UNREAD_MESSAGE_COUNT); - when(notificationData.getContentForSummaryNotification()).thenReturn( - Arrays.asList(content1, content2, content3)); - - lockScreenNotification.configureLockScreenNotification(builder, notificationData); - - verify(publicBuilder).setSmallIcon(resourceProvider.getIconNewMail()); - verify(publicBuilder).setNumber(UNREAD_MESSAGE_COUNT); - verify(publicBuilder).setContentTitle(NEW_MESSAGE_COUNT + " new messages"); - verify(publicBuilder).setContentText( - "alice@example.com, Bob , \"Peter Lustig\" "); - verify(builder).setPublicVersion(publicBuilder.build()); - } - - @Test - public void configureLockScreenNotification_SENDERS_makeSureWeGetEnoughSenderNames() throws Exception { - assertTrue(NotificationData.MAX_NUMBER_OF_MESSAGES_FOR_SUMMARY_NOTIFICATION >= - LockScreenNotification.MAX_NUMBER_OF_SENDERS_IN_LOCK_SCREEN_NOTIFICATION); - } - - @Test - public void createCommaSeparatedListOfSenders_withMoreSendersThanShouldBeDisplayed() throws Exception { - NotificationContent content1 = createNotificationContent("alice@example.com"); - NotificationContent content2 = createNotificationContent("bob@example.com"); - NotificationContent content3 = createNotificationContent("cloe@example.com"); - NotificationContent content4 = createNotificationContent("dagobert@example.com"); - NotificationContent content5 = createNotificationContent("ed@example.com"); - NotificationContent content6 = createNotificationContent("fiona@example.com"); - - String result = lockScreenNotification.createCommaSeparatedListOfSenders( - Arrays.asList(content1, content2, content3, content4, content5, content6)); - - assertEquals( - "alice@example.com, bob@example.com, cloe@example.com, dagobert@example.com, ed@example.com", result); - } - - @Test - public void configureLockScreenNotification_MESSAGE_COUNT() throws Exception { - K9.setLockScreenNotificationVisibility(LockScreenNotificationVisibility.MESSAGE_COUNT); - when(notificationData.getNewMessagesCount()).thenReturn(NEW_MESSAGE_COUNT); - when(notificationData.getUnreadMessageCount()).thenReturn(UNREAD_MESSAGE_COUNT); - - lockScreenNotification.configureLockScreenNotification(builder, notificationData); - - verify(publicBuilder).setSmallIcon(resourceProvider.getIconNewMail()); - verify(publicBuilder).setNumber(UNREAD_MESSAGE_COUNT); - verify(publicBuilder).setContentTitle(NEW_MESSAGE_COUNT + " new messages"); - verify(publicBuilder).setContentText(ACCOUNT_NAME); - verify(builder).setPublicVersion(publicBuilder.build()); - } - - private Account createFakeAccount() { - Account account = mock(Account.class); - when(account.getDescription()).thenReturn(ACCOUNT_NAME); - return account; - } - - private Builder createFakeNotificationBuilder() { - Builder builder = MockHelper.mockBuilder(Builder.class); - when(builder.build()).thenReturn(mock(Notification.class)); - return builder; - } - - private NotificationHelper createFakeNotificationHelper(Context context, Builder builder) { - NotificationHelper notificationHelper = mock(NotificationHelper.class); - when(notificationHelper.getContext()).thenReturn(context); - when(notificationHelper.getAccountName(any(Account.class))).thenReturn(ACCOUNT_NAME); - when(notificationHelper.createNotificationBuilder(any(Account.class), any(NotificationChannelManager - .ChannelType.class))).thenReturn(builder); - - return notificationHelper; - } - - private NotificationData createFakeNotificationData(Account account) { - NotificationData notificationData = mock(NotificationData.class); - when(notificationData.getAccount()).thenReturn(account); - - return notificationData; - } - - private NotificationContent createNotificationContent(String sender) { - return new NotificationContent(null, sender, null, null, null, false); - } -} 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..873dd7cb5f7f4f683e4ea070b14aabd7526e5d49 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/NewMailNotificationManagerTest.kt @@ -0,0 +1,457 @@ +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.fsck.k9.mailstore.LocalStore +import com.fsck.k9.mailstore.LocalStoreProvider +import com.fsck.k9.mailstore.MessageStoreManager +import com.fsck.k9.mailstore.NotificationMessage +import com.google.common.truth.Truth.assertThat +import kotlin.test.assertNotNull +import org.junit.Test +import org.mockito.kotlin.doAnswer +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 mockedNotificationMessages = mutableListOf() + private val account = createAccount() + private val notificationContentCreator = mock() + private val localStoreProvider = createLocalStoreProvider() + private val clock = TestClock(TIMESTAMP) + private val manager = NewMailNotificationManager( + notificationContentCreator, + createNotificationRepository(), + 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.removeNewMailNotifications(account, clearNewMessageState = true) { + listOf(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.removeNewMailNotifications(account, clearNewMessageState = true) { + listOf(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.removeNewMailNotifications(account, clearNewMessageState = true) { + listOf(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.removeNewMailNotifications(account, clearNewMessageState = true) { + listOf(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.removeNewMailNotifications(account, clearNewMessageState = true) { + listOf(createMessageReference("msg-1")) + } + + assertNotNull(result) { data -> + assertThat(data.cancelNotificationIds).hasSize(1) + assertThat(data.baseNotificationData.newMessagesCount) + .isEqualTo(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" + ) + ) + } + } + + @Test + fun `restore notifications without persisted notifications`() { + val result = manager.restoreNewMailNotifications(account) + + assertThat(result).isNull() + } + + @Test + fun `restore notifications with single persisted notification`() { + addNotificationMessage( + notificationId = 10, + timestamp = 20L, + sender = "Sender", + subject = "Subject", + summary = "Summary", + preview = "Preview", + messageUid = "uid-1" + ) + + val result = manager.restoreNewMailNotifications(account) + + assertNotNull(result) { data -> + assertThat(data.cancelNotificationIds).isEmpty() + assertThat(data.baseNotificationData.newMessagesCount).isEqualTo(1) + assertThat(data.singleNotificationData).hasSize(1) + + val singleNotificationData = data.singleNotificationData.first() + assertThat(singleNotificationData.notificationId).isEqualTo(10) + assertThat(singleNotificationData.isSilent).isTrue() + assertThat(singleNotificationData.addLockScreenNotification).isTrue() + assertThat(singleNotificationData.content).isEqualTo( + NotificationContent( + messageReference = createMessageReference("uid-1"), + sender = "Sender", + subject = "Subject", + preview = "Preview", + summary = "Summary" + ) + ) + + assertThat(data.summaryNotificationData).isInstanceOf(SummarySingleNotificationData::class.java) + val summaryNotificationData = data.summaryNotificationData as SummarySingleNotificationData + assertThat(summaryNotificationData.singleNotificationData.isSilent).isTrue() + assertThat(summaryNotificationData.singleNotificationData.content).isEqualTo( + NotificationContent( + messageReference = createMessageReference("uid-1"), + sender = "Sender", + subject = "Subject", + preview = "Preview", + summary = "Summary" + ) + ) + } + } + + @Test + fun `restore notifications with one inactive persisted notification`() { + addMaximumNumberOfNotificationMessages() + addNotificationMessage( + notificationId = null, + timestamp = 1000L, + sender = "inactive", + subject = "inactive", + summary = "inactive", + preview = "inactive", + messageUid = "uid-inactive" + ) + + val result = manager.restoreNewMailNotifications(account) + + assertNotNull(result) { data -> + assertThat(data.cancelNotificationIds).isEmpty() + assertThat(data.baseNotificationData.newMessagesCount) + .isEqualTo(MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS + 1) + assertThat(data.singleNotificationData).hasSize(MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS) + assertThat(data.singleNotificationData.map { it.content.sender }).doesNotContain("inactive") + + assertThat(data.summaryNotificationData).isInstanceOf(SummaryInboxNotificationData::class.java) + val summaryNotificationData = data.summaryNotificationData as SummaryInboxNotificationData + assertThat(summaryNotificationData.isSilent).isTrue() + } + } + + private fun createAccount(): Account { + return Account(ACCOUNT_UUID).apply { + description = ACCOUNT_NAME + chipColor = ACCOUNT_COLOR + } + } + + private fun addMaximumNumberOfNotifications() { + repeat(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 addNotificationMessage( + notificationId: Int?, + timestamp: Long, + sender: String, + subject: String, + preview: String, + summary: String, + messageUid: String + ) { + val message = mock() + + val notificationMessage = NotificationMessage(message, notificationId, timestamp) + mockedNotificationMessages.add(notificationMessage) + + stubbing(notificationContentCreator) { + on { createFromMessage(account, message) } doReturn + NotificationContent( + messageReference = createMessageReference(messageUid), + sender, subject, preview, summary + ) + } + } + + private fun addMaximumNumberOfNotificationMessages() { + repeat(MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS) { index -> + addNotificationMessage( + notificationId = index, + timestamp = index.toLong(), + sender = "irrelevant", + subject = "irrelevant", + preview = "irrelevant", + summary = "irrelevant", + messageUid = "uid-$index" + ) + } + } + + private fun createMessageReference(messageUid: String): MessageReference { + return MessageReference(ACCOUNT_UUID, FOLDER_ID, messageUid) + } + + private fun createLocalStoreProvider(): LocalStoreProvider { + val localStore = createLocalStore() + return mock { + on { getInstance(account) } doReturn localStore + } + } + + private fun createLocalStore(): LocalStore { + return mock { + on { notificationMessages } doAnswer { mockedNotificationMessages.toList() } + } + } + + private fun createNotificationRepository(): NotificationRepository { + val notificationStoreProvider = mock { + on { getNotificationStore(account) } doReturn mock() + } + val messageStoreManager = mock { + on { getMessageStore(account) } doReturn mock() + } + + return NotificationRepository( + notificationStoreProvider, + localStoreProvider, + messageStoreManager, + notificationContentCreator + ) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/NewMailNotificationsTest.java b/app/core/src/test/java/com/fsck/k9/notification/NewMailNotificationsTest.java deleted file mode 100644 index ed0a1d6a07218cd8cd1c668f97464cf5441b2e32..0000000000000000000000000000000000000000 --- a/app/core/src/test/java/com/fsck/k9/notification/NewMailNotificationsTest.java +++ /dev/null @@ -1,377 +0,0 @@ -package com.fsck.k9.notification; - - -import android.app.Notification; -import androidx.core.app.NotificationManagerCompat; - -import com.fsck.k9.Account; -import com.fsck.k9.K9; -import com.fsck.k9.K9.NotificationHideSubject; -import com.fsck.k9.K9RobolectricTest; -import com.fsck.k9.controller.MessageReference; -import com.fsck.k9.mailstore.LocalMessage; -import org.junit.Before; -import org.junit.Test; - -import static org.mockito.Matchers.any; -import static org.mockito.Matchers.anyBoolean; -import static org.mockito.Matchers.anyInt; -import static org.mockito.Matchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - - -public class NewMailNotificationsTest extends K9RobolectricTest { - private static final int ACCOUNT_NUMBER = 23; - - private Account account; - private TestNewMailNotifications newMailNotifications; - private NotificationManagerCompat notificationManager; - private NotificationContentCreator contentCreator; - private DeviceNotifications deviceNotifications; - private WearNotifications wearNotifications; - - - @Before - public void setUp() throws Exception { - account = createAccount(); - - notificationManager = createNotificationManager(); - NotificationHelper notificationHelper = createNotificationHelper(notificationManager); - contentCreator = createNotificationContentCreator(); - deviceNotifications = createDeviceNotifications(); - wearNotifications = createWearNotifications(); - - newMailNotifications = new TestNewMailNotifications(notificationHelper, contentCreator, deviceNotifications, - wearNotifications); - } - - @Test - public void testAddNewMailNotification() throws Exception { - int notificationIndex = 0; - LocalMessage message = createLocalMessage(); - NotificationContent content = createNotificationContent(); - NotificationHolder holder = createNotificationHolder(content, notificationIndex); - addToNotificationContentCreator(message, content); - whenAddingContentReturn(content, AddNotificationResult.newNotification(holder)); - Notification wearNotification = createNotification(); - Notification summaryNotification = createNotification(); - addToWearNotifications(holder, wearNotification); - addToDeviceNotifications(summaryNotification); - - newMailNotifications.addNewMailNotification(account, message, 42); - - int wearNotificationId = NotificationIds.getNewMailStackedNotificationId(account, notificationIndex); - int summaryNotificationId = NotificationIds.getNewMailSummaryNotificationId(account); - verify(notificationManager).notify(wearNotificationId, wearNotification); - verify(notificationManager).notify(summaryNotificationId, summaryNotification); - } - - @Test - public void testAddNewMailNotificationWithCancelingExistingNotification() throws Exception { - int notificationIndex = 0; - LocalMessage message = createLocalMessage(); - NotificationContent content = createNotificationContent(); - NotificationHolder holder = createNotificationHolder(content, notificationIndex); - addToNotificationContentCreator(message, content); - whenAddingContentReturn(content, AddNotificationResult.replaceNotification(holder)); - Notification wearNotification = createNotification(); - Notification summaryNotification = createNotification(); - addToWearNotifications(holder, wearNotification); - addToDeviceNotifications(summaryNotification); - - newMailNotifications.addNewMailNotification(account, message, 42); - - int wearNotificationId = NotificationIds.getNewMailStackedNotificationId(account, notificationIndex); - int summaryNotificationId = NotificationIds.getNewMailSummaryNotificationId(account); - verify(notificationManager).notify(wearNotificationId, wearNotification); - verify(notificationManager).cancel(wearNotificationId); - verify(notificationManager).notify(summaryNotificationId, summaryNotification); - } - - @Test - public void testAddNewMailNotificationWithPrivacyModeEnabled() throws Exception { - enablePrivacyMode(); - int notificationIndex = 0; - LocalMessage message = createLocalMessage(); - NotificationContent content = createNotificationContent(); - NotificationHolder holder = createNotificationHolder(content, notificationIndex); - addToNotificationContentCreator(message, content); - whenAddingContentReturn(content, AddNotificationResult.newNotification(holder)); - Notification wearNotification = createNotification(); - addToDeviceNotifications(wearNotification); - - newMailNotifications.addNewMailNotification(account, message, 42); - - int wearNotificationId = NotificationIds.getNewMailStackedNotificationId(account, notificationIndex); - int summaryNotificationId = NotificationIds.getNewMailSummaryNotificationId(account); - verify(notificationManager, never()).notify(eq(wearNotificationId), any(Notification.class)); - verify(notificationManager).notify(summaryNotificationId, wearNotification); - } - - @Test - public void testAddNewMailNotificationTwice() throws Exception { - int notificationIndexOne = 0; - int notificationIndexTwo = 1; - LocalMessage messageOne = createLocalMessage(); - LocalMessage messageTwo = createLocalMessage(); - NotificationContent contentOne = createNotificationContent(); - NotificationContent contentTwo = createNotificationContent(); - NotificationHolder holderOne = createNotificationHolder(contentOne, notificationIndexOne); - NotificationHolder holderTwo = createNotificationHolder(contentTwo, notificationIndexTwo); - addToNotificationContentCreator(messageOne, contentOne); - addToNotificationContentCreator(messageTwo, contentTwo); - whenAddingContentReturn(contentOne, AddNotificationResult.newNotification(holderOne)); - whenAddingContentReturn(contentTwo, AddNotificationResult.newNotification(holderTwo)); - Notification wearNotificationOne = createNotification(); - Notification wearNotificationTwo = createNotification(); - Notification summaryNotification = createNotification(); - addToWearNotifications(holderOne, wearNotificationOne); - addToWearNotifications(holderTwo, wearNotificationTwo); - addToDeviceNotifications(summaryNotification); - - newMailNotifications.addNewMailNotification(account, messageOne, 42); - newMailNotifications.addNewMailNotification(account, messageTwo, 42); - - int wearNotificationIdOne = NotificationIds.getNewMailStackedNotificationId(account, notificationIndexOne); - int wearNotificationIdTwo = NotificationIds.getNewMailStackedNotificationId(account, notificationIndexTwo); - int summaryNotificationId = NotificationIds.getNewMailSummaryNotificationId(account); - verify(notificationManager).notify(wearNotificationIdOne, wearNotificationOne); - verify(notificationManager).notify(wearNotificationIdTwo, wearNotificationTwo); - verify(notificationManager, times(2)).notify(summaryNotificationId, summaryNotification); - } - - @Test - public void testRemoveNewMailNotificationWithoutNotificationData() throws Exception { - MessageReference messageReference = createMessageReference(1); - - newMailNotifications.removeNewMailNotification(account, messageReference); - - verify(notificationManager, never()).cancel(anyInt()); - } - - @Test - public void testRemoveNewMailNotificationWithUnknownMessageReference() throws Exception { - enablePrivacyMode(); - MessageReference messageReference = createMessageReference(1); - int notificationIndex = 0; - LocalMessage message = createLocalMessage(); - NotificationContent content = createNotificationContent(); - NotificationHolder holder = createNotificationHolder(content, notificationIndex); - addToNotificationContentCreator(message, content); - whenAddingContentReturn(content, AddNotificationResult.newNotification(holder)); - Notification summaryNotification = createNotification(); - addToDeviceNotifications(summaryNotification); - newMailNotifications.addNewMailNotification(account, message, 23); - whenRemovingContentReturn(messageReference, RemoveNotificationResult.unknownNotification()); - - newMailNotifications.removeNewMailNotification(account, messageReference); - - verify(notificationManager, never()).cancel(anyInt()); - } - - @Test - public void testRemoveNewMailNotification() throws Exception { - enablePrivacyMode(); - MessageReference messageReference = createMessageReference(1); - int notificationIndex = 0; - int notificationId = NotificationIds.getNewMailStackedNotificationId(account, notificationIndex); - LocalMessage message = createLocalMessage(); - NotificationContent content = createNotificationContent(); - NotificationHolder holder = createNotificationHolder(content, notificationIndex); - addToNotificationContentCreator(message, content); - whenAddingContentReturn(content, AddNotificationResult.newNotification(holder)); - Notification summaryNotification = createNotification(); - addToDeviceNotifications(summaryNotification); - newMailNotifications.addNewMailNotification(account, message, 23); - whenRemovingContentReturn(messageReference, RemoveNotificationResult.cancelNotification(notificationId)); - - newMailNotifications.removeNewMailNotification(account, messageReference); - - int summaryNotificationId = NotificationIds.getNewMailSummaryNotificationId(account); - verify(notificationManager).cancel(notificationId); - verify(notificationManager, times(2)).notify(summaryNotificationId, summaryNotification); - } - - @Test - public void testRemoveNewMailNotificationClearingAllNotifications() throws Exception { - MessageReference messageReference = createMessageReference(1); - int notificationIndex = 0; - int notificationId = NotificationIds.getNewMailStackedNotificationId(account, notificationIndex); - LocalMessage message = createLocalMessage(); - NotificationContent content = createNotificationContent(); - NotificationHolder holder = createNotificationHolder(content, notificationIndex); - addToNotificationContentCreator(message, content); - whenAddingContentReturn(content, AddNotificationResult.newNotification(holder)); - Notification summaryNotification = createNotification(); - addToDeviceNotifications(summaryNotification); - newMailNotifications.addNewMailNotification(account, message, 23); - whenRemovingContentReturn(messageReference, RemoveNotificationResult.cancelNotification(notificationId)); - when(newMailNotifications.notificationData.getNewMessagesCount()).thenReturn(0); - setActiveNotificationIds(); - - newMailNotifications.removeNewMailNotification(account, messageReference); - - int summaryNotificationId = NotificationIds.getNewMailSummaryNotificationId(account); - verify(notificationManager).cancel(notificationId); - verify(notificationManager).cancel(summaryNotificationId); - } - - @Test - public void testRemoveNewMailNotificationWithCreateNotification() throws Exception { - MessageReference messageReference = createMessageReference(1); - int notificationIndex = 0; - int notificationId = NotificationIds.getNewMailStackedNotificationId(account, notificationIndex); - LocalMessage message = createLocalMessage(); - NotificationContent contentOne = createNotificationContent(); - NotificationContent contentTwo = createNotificationContent(); - NotificationHolder holderOne = createNotificationHolder(contentOne, notificationIndex); - NotificationHolder holderTwo = createNotificationHolder(contentTwo, notificationIndex); - addToNotificationContentCreator(message, contentOne); - whenAddingContentReturn(contentOne, AddNotificationResult.newNotification(holderOne)); - Notification summaryNotification = createNotification(); - addToDeviceNotifications(summaryNotification); - Notification wearNotificationOne = createNotification(); - Notification wearNotificationTwo = createNotification(); - addToWearNotifications(holderOne, wearNotificationOne); - addToWearNotifications(holderTwo, wearNotificationTwo); - newMailNotifications.addNewMailNotification(account, message, 23); - whenRemovingContentReturn(messageReference, RemoveNotificationResult.createNotification(holderTwo)); - - newMailNotifications.removeNewMailNotification(account, messageReference); - - int summaryNotificationId = NotificationIds.getNewMailSummaryNotificationId(account); - verify(notificationManager).cancel(notificationId); - verify(notificationManager).notify(notificationId, wearNotificationTwo); - verify(notificationManager, times(2)).notify(summaryNotificationId, summaryNotification); - } - - @Test - public void testClearNewMailNotificationsWithoutNotificationData() throws Exception { - newMailNotifications.clearNewMailNotifications(account); - - verify(notificationManager, never()).cancel(anyInt()); - } - - @Test - public void testClearNewMailNotifications() throws Exception { - int notificationIndex = 0; - int notificationId = NotificationIds.getNewMailStackedNotificationId(account, notificationIndex); - LocalMessage message = createLocalMessage(); - NotificationContent content = createNotificationContent(); - NotificationHolder holder = createNotificationHolder(content, notificationIndex); - addToNotificationContentCreator(message, content); - setActiveNotificationIds(notificationId); - whenAddingContentReturn(content, AddNotificationResult.newNotification(holder)); - newMailNotifications.addNewMailNotification(account, message, 3); - - newMailNotifications.clearNewMailNotifications(account); - - verify(notificationManager).cancel(notificationId); - verify(notificationManager).cancel(NotificationIds.getNewMailSummaryNotificationId(account)); - } - - private Account createAccount() { - Account account = mock(Account.class); - when(account.getAccountNumber()).thenReturn(ACCOUNT_NUMBER); - return account; - } - - private LocalMessage createLocalMessage() { - return mock(LocalMessage.class); - } - - private NotificationContent createNotificationContent() { - return new NotificationContent(null, null, null, null, null, false); - } - - private NotificationHolder createNotificationHolder(NotificationContent content, int index) { - int notificationId = NotificationIds.getNewMailStackedNotificationId(account, index); - return new NotificationHolder(notificationId, content); - } - - private NotificationManagerCompat createNotificationManager() { - return mock(NotificationManagerCompat.class); - } - - private NotificationHelper createNotificationHelper(NotificationManagerCompat notificationManager) { - NotificationHelper notificationHelper = mock(NotificationHelper.class); - when(notificationHelper.getNotificationManager()).thenReturn(notificationManager); - return notificationHelper; - } - - private NotificationContentCreator createNotificationContentCreator() { - return mock(NotificationContentCreator.class); - } - - private void addToNotificationContentCreator(LocalMessage message, NotificationContent content) { - when(contentCreator.createFromMessage(account, message)).thenReturn(content); - } - - private DeviceNotifications createDeviceNotifications() { - return mock(DeviceNotifications.class); - } - - private void addToDeviceNotifications(Notification notificationToReturn) { - when(deviceNotifications.buildSummaryNotification( - eq(account), eq(newMailNotifications.notificationData), anyBoolean()) - ).thenReturn(notificationToReturn); - } - - private Notification createNotification() { - return mock(Notification.class); - } - - private WearNotifications createWearNotifications() { - return mock(WearNotifications.class); - } - - private MessageReference createMessageReference(int number) { - return new MessageReference("account", 1, String.valueOf(number), null); - } - - private void addToWearNotifications(NotificationHolder notificationHolder, Notification notificationToReturn) { - when(wearNotifications.buildStackedNotification(account, notificationHolder)).thenReturn(notificationToReturn); - } - - private void whenAddingContentReturn(NotificationContent content, AddNotificationResult result) { - NotificationData notificationData = newMailNotifications.notificationData; - when(notificationData.addNotificationContent(content)).thenReturn(result); - - int newCount = notificationData.getNewMessagesCount() + 1; - when(notificationData.getNewMessagesCount()).thenReturn(newCount); - } - - private void whenRemovingContentReturn(MessageReference messageReference, RemoveNotificationResult result) { - NotificationData notificationData = newMailNotifications.notificationData; - when(notificationData.removeNotificationForMessage(messageReference)).thenReturn(result); - } - - private void setActiveNotificationIds(int... notificationIds) { - NotificationData notificationData = newMailNotifications.notificationData; - when(notificationData.getActiveNotificationIds()).thenReturn(notificationIds); - } - - private void enablePrivacyMode() { - K9.setNotificationHideSubject(NotificationHideSubject.ALWAYS); - } - - static class TestNewMailNotifications extends NewMailNotifications { - - public final NotificationData notificationData; - - TestNewMailNotifications(NotificationHelper notificationHelper, NotificationContentCreator contentCreator, - DeviceNotifications deviceNotifications, WearNotifications wearNotifications) { - super(notificationHelper, contentCreator, deviceNotifications, wearNotifications); - notificationData = mock(NotificationData.class); - } - - @Override - NotificationData createNotificationData(Account account, int unreadMessageCount) { - return notificationData; - } - } -} diff --git a/app/core/src/test/java/com/fsck/k9/notification/NotificationContentCreatorTest.java b/app/core/src/test/java/com/fsck/k9/notification/NotificationContentCreatorTest.java deleted file mode 100644 index eeb1b230d5785d003e4a7b1ef3d7076d1766d787..0000000000000000000000000000000000000000 --- a/app/core/src/test/java/com/fsck/k9/notification/NotificationContentCreatorTest.java +++ /dev/null @@ -1,181 +0,0 @@ -package com.fsck.k9.notification; - - -import android.content.Context; - -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; -import org.junit.Before; -import org.junit.Test; -import org.robolectric.RuntimeEnvironment; - -import static org.junit.Assert.assertEquals; -import static org.mockito.Matchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - - -public class NotificationContentCreatorTest extends RobolectricTest { - private static final String ACCOUNT_UUID = "1-2-3"; - private static final long FOLDER_ID = 23; - private static final String FOLDER_NAME = "INBOX"; - private static final String UID = "42"; - private static final String PREVIEW = "Message preview text"; - private static final String SUBJECT = "Message subject"; - private static final String SENDER_ADDRESS = "alice@example.com"; - private static final String SENDER_NAME = "Alice"; - private static final String RECIPIENT_ADDRESS = "bob@example.com"; - private static final String RECIPIENT_NAME = "Bob"; - - - private NotificationResourceProvider resourceProvider = new TestNotificationResourceProvider(); - private NotificationContentCreator contentCreator; - private MessageReference messageReference; - private Account account; - private LocalMessage message; - - - @Before - public void setUp() throws Exception { - contentCreator = createNotificationContentCreator(); - messageReference = createMessageReference(); - account = createFakeAccount(); - message = createFakeLocalMessage(messageReference); - } - - @Test - public void createFromMessage_withRegularMessage() throws Exception { - NotificationContent content = contentCreator.createFromMessage(account, message); - - assertEquals(messageReference, content.messageReference); - assertEquals(SENDER_NAME, content.sender); - assertEquals(SUBJECT, content.subject); - assertEquals(SUBJECT + "\n" + PREVIEW, content.preview.toString()); - assertEquals(SENDER_NAME + " " + SUBJECT, content.summary.toString()); - assertEquals(false, content.starred); - } - - @Test - public void createFromMessage_withoutSubject() throws Exception { - when(message.getSubject()).thenReturn(null); - - NotificationContent content = contentCreator.createFromMessage(account, message); - - String noSubject = "(No subject)"; - assertEquals(noSubject, content.subject); - assertEquals(PREVIEW, content.preview.toString()); - assertEquals(SENDER_NAME + " " + noSubject, content.summary.toString()); - } - - @Test - public void createFromMessage_withoutPreview() throws Exception { - when(message.getPreviewType()).thenReturn(PreviewType.NONE); - when(message.getPreview()).thenReturn(null); - - NotificationContent content = contentCreator.createFromMessage(account, message); - - assertEquals(SUBJECT, content.subject); - assertEquals(SUBJECT, content.preview.toString()); - } - - @Test - public void createFromMessage_withErrorPreview() throws Exception { - when(message.getPreviewType()).thenReturn(PreviewType.ERROR); - when(message.getPreview()).thenReturn(null); - - NotificationContent content = contentCreator.createFromMessage(account, message); - - assertEquals(SUBJECT, content.subject); - assertEquals(SUBJECT, content.preview.toString()); - } - - @Test - public void createFromMessage_withEncryptedMessage() throws Exception { - when(message.getPreviewType()).thenReturn(PreviewType.ENCRYPTED); - when(message.getPreview()).thenReturn(null); - - NotificationContent content = contentCreator.createFromMessage(account, message); - - String encrypted = "*Encrypted*"; - assertEquals(SUBJECT, content.subject); - assertEquals(SUBJECT + "\n" + encrypted, content.preview.toString()); - } - - @Test - public void createFromMessage_withoutSender() throws Exception { - when(message.getFrom()).thenReturn(null); - - NotificationContent content = contentCreator.createFromMessage(account, message); - - assertEquals("No sender", content.sender); - assertEquals(SUBJECT, content.summary.toString()); - } - - @Test - public void createFromMessage_withMessageFromSelf() throws Exception { - when(account.isAnIdentity(any(Address[].class))).thenReturn(true); - - NotificationContent content = contentCreator.createFromMessage(account, message); - - String insteadOfSender = "To:Bob"; - assertEquals(insteadOfSender, content.sender); - assertEquals(insteadOfSender + " " + SUBJECT, content.summary.toString()); - } - - @Test - public void createFromMessage_withStarredMessage() throws Exception { - when(message.isSet(Flag.FLAGGED)).thenReturn(true); - - NotificationContent content = contentCreator.createFromMessage(account, message); - - assertEquals(true, content.starred); - } - - @Test - public void createFromMessage_withoutEmptyMessage() throws Exception { - when(message.getFrom()).thenReturn(null); - when(message.getSubject()).thenReturn(null); - when(message.getPreviewType()).thenReturn(PreviewType.NONE); - when(message.getPreview()).thenReturn(null); - - NotificationContent content = contentCreator.createFromMessage(account, message); - - assertEquals("No sender", content.sender); - assertEquals("(No subject)", content.subject); - assertEquals("(No subject)", content.preview.toString()); - assertEquals("(No subject)", content.summary.toString()); - } - - private NotificationContentCreator createNotificationContentCreator() { - Context context = RuntimeEnvironment.application; - return new NotificationContentCreator(context, resourceProvider); - } - - private Account createFakeAccount() { - return mock(Account.class); - } - - private MessageReference createMessageReference() { - return new MessageReference(ACCOUNT_UUID, FOLDER_ID, UID, null); - } - - private LocalMessage createFakeLocalMessage(MessageReference messageReference) { - LocalMessage message = mock(LocalMessage.class); - - when(message.makeMessageReference()).thenReturn(messageReference); - when(message.getPreviewType()).thenReturn(PreviewType.TEXT); - when(message.getPreview()).thenReturn(PREVIEW); - when(message.getSubject()).thenReturn(SUBJECT); - when(message.getFrom()).thenReturn(new Address[] { new Address(SENDER_ADDRESS, SENDER_NAME) }); - when(message.getRecipients(RecipientType.TO)) - .thenReturn(new Address[] { new Address(RECIPIENT_ADDRESS, RECIPIENT_NAME) }); - - return message; - } -} 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 new file mode 100644 index 0000000000000000000000000000000000000000..c706443137b72cee6e002e7857d4b386697c7ebc --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/NotificationContentCreatorTest.kt @@ -0,0 +1,159 @@ +package com.fsck.k9.notification + +import androidx.test.core.app.ApplicationProvider +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.Message.RecipientType +import com.fsck.k9.mailstore.LocalMessage +import com.fsck.k9.message.extractors.PreviewResult.PreviewType +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.stubbing + +private const val ACCOUNT_UUID = "1-2-3" +private const val FOLDER_ID = 23L +private const val UID = "42" +private const val PREVIEW = "Message preview text" +private const val SUBJECT = "Message subject" +private const val SENDER_ADDRESS = "alice@example.com" +private const val SENDER_NAME = "Alice" +private const val RECIPIENT_ADDRESS = "bob@example.com" +private const val RECIPIENT_NAME = "Bob" + +class NotificationContentCreatorTest : RobolectricTest() { + private val resourceProvider = TestNotificationResourceProvider() + private val contentCreator = createNotificationContentCreator() + private val messageReference = createMessageReference() + private val account = createFakeAccount() + private val message = createFakeLocalMessage(messageReference) + + @Test + fun createFromMessage_withRegularMessage() { + val content = contentCreator.createFromMessage(account, message) + + assertThat(content.messageReference).isEqualTo(messageReference) + assertThat(content.sender).isEqualTo(SENDER_NAME) + assertThat(content.subject).isEqualTo(SUBJECT) + assertThat(content.preview.toString()).isEqualTo("$SUBJECT\n$PREVIEW") + assertThat(content.summary.toString()).isEqualTo("$SENDER_NAME $SUBJECT") + } + + @Test + fun createFromMessage_withoutSubject() { + 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)") + } + + @Test + fun createFromMessage_withoutPreview() { + stubbing(message) { + 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) + } + + @Test + fun createFromMessage_withErrorPreview() { + stubbing(message) { + 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) + } + + @Test + fun createFromMessage_withEncryptedMessage() { + stubbing(message) { + 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*") + } + + @Test + fun createFromMessage_withoutSender() { + stubbing(message) { + on { from } doReturn null + } + + val content = contentCreator.createFromMessage(account, message) + + assertThat(content.sender).isEqualTo("No sender") + assertThat(content.summary.toString()).isEqualTo(SUBJECT) + } + + @Test + fun createFromMessage_withMessageFromSelf() { + stubbing(account) { + on { isAnIdentity(any>()) } doReturn true + } + + val content = contentCreator.createFromMessage(account, message) + + assertThat(content.sender).isEqualTo("To:Bob") + assertThat(content.summary.toString()).isEqualTo("To:Bob $SUBJECT") + } + + @Test + fun createFromMessage_withoutEmptyMessage() { + stubbing(message) { + on { from } doReturn null + on { subject } doReturn null + on { previewType } doReturn PreviewType.NONE + on { preview } doReturn null + } + + val content = contentCreator.createFromMessage(account, message) + + assertThat(content.sender).isEqualTo("No sender") + assertThat(content.subject).isEqualTo("(No subject)") + assertThat(content.preview.toString()).isEqualTo("(No subject)") + assertThat(content.summary.toString()).isEqualTo("(No subject)") + } + + private fun createNotificationContentCreator(): NotificationContentCreator { + return NotificationContentCreator(ApplicationProvider.getApplicationContext(), resourceProvider) + } + + private fun createFakeAccount(): Account = mock() + + private fun createMessageReference(): MessageReference { + return MessageReference(ACCOUNT_UUID, FOLDER_ID, UID) + } + + private fun createFakeLocalMessage(messageReference: MessageReference): LocalMessage { + return mock { + on { makeMessageReference() } doReturn messageReference + on { previewType } doReturn PreviewType.TEXT + on { preview } doReturn PREVIEW + on { subject } doReturn SUBJECT + on { from } doReturn arrayOf(Address(SENDER_ADDRESS, SENDER_NAME)) + on { getRecipients(RecipientType.TO) } doReturn arrayOf(Address(RECIPIENT_ADDRESS, RECIPIENT_NAME)) + } + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/NotificationDataStoreTest.kt b/app/core/src/test/java/com/fsck/k9/notification/NotificationDataStoreTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..9bb2c110bb2fd919c5008145a5014d6d75668fb9 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/NotificationDataStoreTest.kt @@ -0,0 +1,154 @@ +package com.fsck.k9.notification + +import com.fsck.k9.Account +import com.fsck.k9.RobolectricTest +import com.fsck.k9.controller.MessageReference +import com.google.common.truth.Truth.assertThat +import kotlin.test.assertNotNull +import org.junit.Test + +private const val ACCOUNT_UUID = "1-2-3" +private const val ACCOUNT_NUMBER = 23 +private const val FOLDER_ID = 42L +private const val TIMESTAMP = 0L + +class NotificationDataStoreTest : RobolectricTest() { + private val account = createAccount() + private val notificationDataStore = NotificationDataStore() + + @Test + fun testAddNotificationContent() { + val content = createNotificationContent("1") + + val result = notificationDataStore.addNotification(account, content, TIMESTAMP) + + assertThat(result.shouldCancelNotification).isFalse() + + val holder = result.notificationHolder + + assertThat(holder).isNotNull() + assertThat(holder.notificationId).isEqualTo(NotificationIds.getSingleMessageNotificationId(account, 0)) + assertThat(holder.content).isEqualTo(content) + } + + @Test + fun testAddNotificationContentWithReplacingNotification() { + notificationDataStore.addNotification(account, createNotificationContent("1"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("2"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("3"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("4"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("5"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("6"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("7"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("8"), TIMESTAMP) + + val result = notificationDataStore.addNotification(account, createNotificationContent("9"), TIMESTAMP) + + assertThat(result.shouldCancelNotification).isTrue() + assertThat(result.cancelNotificationId).isEqualTo(NotificationIds.getSingleMessageNotificationId(account, 0)) + } + + @Test + fun testRemoveNotificationForMessage() { + val content = createNotificationContent("1") + notificationDataStore.addNotification(account, content, TIMESTAMP) + + val result = notificationDataStore.removeNotifications(account) { listOf(content.messageReference) } + + assertNotNull(result) { removeResult -> + assertThat(removeResult.cancelNotificationIds) + .containsExactly(NotificationIds.getSingleMessageNotificationId(account, 0)) + assertThat(removeResult.notificationHolders).isEmpty() + } + } + + @Test + fun testRemoveNotificationForMessageWithRecreatingNotification() { + notificationDataStore.addNotification(account, createNotificationContent("1"), TIMESTAMP) + val content = createNotificationContent("2") + notificationDataStore.addNotification(account, content, TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("3"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("4"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("5"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("6"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("7"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("8"), TIMESTAMP) + notificationDataStore.addNotification(account, createNotificationContent("9"), TIMESTAMP) + val latestContent = createNotificationContent("10") + notificationDataStore.addNotification(account, latestContent, TIMESTAMP) + + val result = notificationDataStore.removeNotifications(account) { listOf(latestContent.messageReference) } + + assertNotNull(result) { removeResult -> + assertThat(removeResult.cancelNotificationIds) + .containsExactly(NotificationIds.getSingleMessageNotificationId(account, 1)) + assertThat(removeResult.notificationHolders).hasSize(1) + + val holder = removeResult.notificationHolders.first() + assertThat(holder.notificationId).isEqualTo(NotificationIds.getSingleMessageNotificationId(account, 1)) + assertThat(holder.content).isEqualTo(content) + } + } + + @Test + fun testRemoveDoesNotLeakNotificationIds() { + for (i in 1..MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS + 1) { + val content = createNotificationContent(i.toString()) + notificationDataStore.addNotification(account, content, TIMESTAMP) + notificationDataStore.removeNotifications(account) { listOf(content.messageReference) } + } + } + + @Test + fun testNewMessagesCount() { + val contentOne = createNotificationContent("1") + val resultOne = notificationDataStore.addNotification(account, contentOne, TIMESTAMP) + assertThat(resultOne.notificationData.newMessagesCount).isEqualTo(1) + + val contentTwo = createNotificationContent("2") + val resultTwo = notificationDataStore.addNotification(account, contentTwo, TIMESTAMP) + assertThat(resultTwo.notificationData.newMessagesCount).isEqualTo(2) + } + + @Test + fun testIsSingleMessageNotification() { + val resultOne = notificationDataStore.addNotification(account, createNotificationContent("1"), TIMESTAMP) + assertThat(resultOne.notificationData.isSingleMessageNotification).isTrue() + + val resultTwo = notificationDataStore.addNotification(account, createNotificationContent("2"), TIMESTAMP) + assertThat(resultTwo.notificationData.isSingleMessageNotification).isFalse() + } + + @Test + fun testGetHolderForLatestNotification() { + val content = createNotificationContent("1") + val addResult = notificationDataStore.addNotification(account, content, TIMESTAMP) + + assertThat(addResult.notificationData.activeNotifications.first()).isEqualTo(addResult.notificationHolder) + } + + private fun createAccount(): Account { + return Account("00000000-0000-4000-0000-000000000000").apply { + accountNumber = ACCOUNT_NUMBER + } + } + + private fun createMessageReference(uid: String): MessageReference { + return MessageReference(ACCOUNT_UUID, FOLDER_ID, uid) + } + + private fun createNotificationContent(uid: String): NotificationContent { + val messageReference = createMessageReference(uid) + return createNotificationContent(messageReference) + } + + private fun createNotificationContent(messageReference: MessageReference): NotificationContent { + return NotificationContent( + messageReference = messageReference, + sender = "irrelevant", + subject = "irrelevant", + preview = "irrelevant", + summary = "irrelevant" + ) + } +} diff --git a/app/core/src/test/java/com/fsck/k9/notification/NotificationDataTest.java b/app/core/src/test/java/com/fsck/k9/notification/NotificationDataTest.java deleted file mode 100644 index 9fc5456eb15d4989fccccd29db46d03a78baab27..0000000000000000000000000000000000000000 --- a/app/core/src/test/java/com/fsck/k9/notification/NotificationDataTest.java +++ /dev/null @@ -1,315 +0,0 @@ -package com.fsck.k9.notification; - - -import java.util.List; - -import com.fsck.k9.Account; -import com.fsck.k9.RobolectricTest; -import com.fsck.k9.controller.MessageReference; -import org.junit.Before; -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - - -public class NotificationDataTest extends RobolectricTest { - private static final String ACCOUNT_UUID = "1-2-3"; - private static final int ACCOUNT_NUMBER = 23; - private static final long FOLDER_ID = 42; - private static final String FOLDER_NAME = "INBOX"; - - - private NotificationData notificationData; - private Account account; - - - @Before - public void setUp() throws Exception { - account = createFakeAccount(); - notificationData = new NotificationData(account); - } - - @Test - public void testAddNotificationContent() throws Exception { - NotificationContent content = createNotificationContent("1"); - - AddNotificationResult result = notificationData.addNotificationContent(content); - - assertFalse(result.shouldCancelNotification()); - NotificationHolder holder = result.getNotificationHolder(); - assertNotNull(holder); - assertEquals(NotificationIds.getNewMailStackedNotificationId(account, 0), holder.notificationId); - assertEquals(content, holder.content); - } - - @Test - public void testAddNotificationContentWithReplacingNotification() throws Exception { - 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")); - - AddNotificationResult result = notificationData.addNotificationContent(createNotificationContent("9")); - - assertTrue(result.shouldCancelNotification()); - assertEquals(NotificationIds.getNewMailStackedNotificationId(account, 0), result.getNotificationId()); - } - - @Test - public void testRemoveNotificationForMessage() throws Exception { - NotificationContent content = createNotificationContent("1"); - notificationData.addNotificationContent(content); - - RemoveNotificationResult result = notificationData.removeNotificationForMessage(content.messageReference); - - assertFalse(result.isUnknownNotification()); - assertEquals(NotificationIds.getNewMailStackedNotificationId(account, 0), result.getNotificationId()); - assertFalse(result.shouldCreateNotification()); - } - - @Test - public void testRemoveNotificationForMessageWithRecreatingNotification() throws Exception { - notificationData.addNotificationContent(createNotificationContent("1")); - NotificationContent content = createNotificationContent("2"); - notificationData.addNotificationContent(content); - notificationData.addNotificationContent(createNotificationContent("3")); - notificationData.addNotificationContent(createNotificationContent("4")); - notificationData.addNotificationContent(createNotificationContent("5")); - notificationData.addNotificationContent(createNotificationContent("6")); - notificationData.addNotificationContent(createNotificationContent("7")); - notificationData.addNotificationContent(createNotificationContent("8")); - notificationData.addNotificationContent(createNotificationContent("9")); - NotificationContent latestContent = createNotificationContent("10"); - notificationData.addNotificationContent(latestContent); - - RemoveNotificationResult result = - notificationData.removeNotificationForMessage(latestContent.messageReference); - - assertFalse(result.isUnknownNotification()); - assertEquals(NotificationIds.getNewMailStackedNotificationId(account, 1), result.getNotificationId()); - assertTrue(result.shouldCreateNotification()); - NotificationHolder holder = result.getNotificationHolder(); - assertNotNull(holder); - assertEquals(NotificationIds.getNewMailStackedNotificationId(account, 1), holder.notificationId); - assertEquals(content, holder.content); - } - - @Test - public void testRemoveDoesNotLeakNotificationIds() { - for (int i = 1; i <= NotificationData.MAX_NUMBER_OF_STACKED_NOTIFICATIONS + 1; i++) { - NotificationContent content = createNotificationContent("" + i); - notificationData.addNotificationContent(content); - notificationData.removeNotificationForMessage(content.messageReference); - } - } - - @Test - public void testNewMessagesCount() throws Exception { - assertEquals(0, notificationData.getNewMessagesCount()); - - NotificationContent contentOne = createNotificationContent("1"); - notificationData.addNotificationContent(contentOne); - assertEquals(1, notificationData.getNewMessagesCount()); - - NotificationContent contentTwo = createNotificationContent("2"); - notificationData.addNotificationContent(contentTwo); - assertEquals(2, notificationData.getNewMessagesCount()); - } - - @Test - public void testUnreadMessagesCount() throws Exception { - notificationData.setUnreadMessageCount(42); - assertEquals(42, notificationData.getUnreadMessageCount()); - - NotificationContent content = createNotificationContent("1"); - notificationData.addNotificationContent(content); - assertEquals(43, notificationData.getUnreadMessageCount()); - - NotificationContent contentTwo = createNotificationContent("2"); - notificationData.addNotificationContent(contentTwo); - assertEquals(44, notificationData.getUnreadMessageCount()); - } - - @Test - public void testContainsStarredMessages() throws Exception { - assertFalse(notificationData.containsStarredMessages()); - - notificationData.addNotificationContent(createNotificationContentForStarredMessage()); - - assertTrue(notificationData.containsStarredMessages()); - } - - @Test - public void testContainsStarredMessagesWithAdditionalMessages() throws Exception { - 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")); - - assertFalse(notificationData.containsStarredMessages()); - - notificationData.addNotificationContent(createNotificationContentForStarredMessage()); - - assertTrue(notificationData.containsStarredMessages()); - } - - @Test - public void testIsSingleMessageNotification() throws Exception { - assertFalse(notificationData.isSingleMessageNotification()); - - notificationData.addNotificationContent(createNotificationContent("1")); - assertTrue(notificationData.isSingleMessageNotification()); - - notificationData.addNotificationContent(createNotificationContent("2")); - assertFalse(notificationData.isSingleMessageNotification()); - } - - @Test - public void testGetHolderForLatestNotification() throws Exception { - NotificationContent content = createNotificationContent("1"); - AddNotificationResult addResult = notificationData.addNotificationContent(content); - - NotificationHolder holder = notificationData.getHolderForLatestNotification(); - - assertEquals(addResult.getNotificationHolder(), holder); - } - - @Test - public void testGetContentForSummaryNotification() throws Exception { - notificationData.addNotificationContent(createNotificationContent("1")); - NotificationContent content4 = createNotificationContent("2"); - notificationData.addNotificationContent(content4); - NotificationContent content3 = createNotificationContent("3"); - notificationData.addNotificationContent(content3); - NotificationContent content2 = createNotificationContent("4"); - notificationData.addNotificationContent(content2); - NotificationContent content1 = createNotificationContent("5"); - notificationData.addNotificationContent(content1); - NotificationContent content0 = createNotificationContent("6"); - notificationData.addNotificationContent(content0); - - List contents = notificationData.getContentForSummaryNotification(); - - assertEquals(5, contents.size()); - assertEquals(content0, contents.get(0)); - assertEquals(content1, contents.get(1)); - assertEquals(content2, contents.get(2)); - assertEquals(content3, contents.get(3)); - assertEquals(content4, contents.get(4)); - } - - @Test - public void testGetActiveNotificationIds() throws Exception { - notificationData.addNotificationContent(createNotificationContent("1")); - notificationData.addNotificationContent(createNotificationContent("2")); - - int[] notificationIds = notificationData.getActiveNotificationIds(); - - assertEquals(2, notificationIds.length); - assertEquals(NotificationIds.getNewMailStackedNotificationId(account, 1), notificationIds[0]); - assertEquals(NotificationIds.getNewMailStackedNotificationId(account, 0), notificationIds[1]); - } - - @Test - public void testGetAccount() throws Exception { - assertEquals(account, notificationData.getAccount()); - } - - @Test - public void testGetAllMessageReferences() throws Exception { - MessageReference messageReference0 = createMessageReference("1"); - MessageReference messageReference1 = createMessageReference("2"); - MessageReference messageReference2 = createMessageReference("3"); - MessageReference messageReference3 = createMessageReference("4"); - MessageReference messageReference4 = createMessageReference("5"); - MessageReference messageReference5 = createMessageReference("6"); - MessageReference messageReference6 = createMessageReference("7"); - MessageReference messageReference7 = createMessageReference("8"); - MessageReference messageReference8 = createMessageReference("9"); - notificationData.addNotificationContent(createNotificationContent(messageReference8)); - notificationData.addNotificationContent(createNotificationContent(messageReference7)); - notificationData.addNotificationContent(createNotificationContent(messageReference6)); - notificationData.addNotificationContent(createNotificationContent(messageReference5)); - notificationData.addNotificationContent(createNotificationContent(messageReference4)); - notificationData.addNotificationContent(createNotificationContent(messageReference3)); - notificationData.addNotificationContent(createNotificationContent(messageReference2)); - notificationData.addNotificationContent(createNotificationContent(messageReference1)); - notificationData.addNotificationContent(createNotificationContent(messageReference0)); - - List messageReferences = notificationData.getAllMessageReferences(); - - assertEquals(9, messageReferences.size()); - assertEquals(messageReference0, messageReferences.get(0)); - assertEquals(messageReference1, messageReferences.get(1)); - assertEquals(messageReference2, messageReferences.get(2)); - assertEquals(messageReference3, messageReferences.get(3)); - assertEquals(messageReference4, messageReferences.get(4)); - assertEquals(messageReference5, messageReferences.get(5)); - assertEquals(messageReference6, messageReferences.get(6)); - assertEquals(messageReference7, messageReferences.get(7)); - assertEquals(messageReference8, messageReferences.get(8)); - } - - @Test - public void testOverflowNotifications() { - MessageReference messageReference0 = createMessageReference("1"); - MessageReference messageReference1 = createMessageReference("2"); - MessageReference messageReference2 = createMessageReference("3"); - MessageReference messageReference3 = createMessageReference("4"); - MessageReference messageReference4 = createMessageReference("5"); - MessageReference messageReference5 = createMessageReference("6"); - MessageReference messageReference6 = createMessageReference("7"); - MessageReference messageReference7 = createMessageReference("8"); - MessageReference messageReference8 = createMessageReference("9"); - - notificationData.addNotificationContent(createNotificationContent(messageReference8)); - notificationData.addNotificationContent(createNotificationContent(messageReference7)); - notificationData.addNotificationContent(createNotificationContent(messageReference6)); - notificationData.addNotificationContent(createNotificationContent(messageReference5)); - notificationData.addNotificationContent(createNotificationContent(messageReference4)); - notificationData.addNotificationContent(createNotificationContent(messageReference3)); - notificationData.addNotificationContent(createNotificationContent(messageReference2)); - notificationData.addNotificationContent(createNotificationContent(messageReference1)); - notificationData.addNotificationContent(createNotificationContent(messageReference0)); - - assertTrue(notificationData.hasSummaryOverflowMessages()); - assertEquals(4, notificationData.getSummaryOverflowMessagesCount()); - } - - private Account createFakeAccount() { - Account account = mock(Account.class); - when(account.getAccountNumber()).thenReturn(ACCOUNT_NUMBER); - return account; - } - - private MessageReference createMessageReference(String uid) { - return new MessageReference(ACCOUNT_UUID, FOLDER_ID, uid, null); - } - - private NotificationContent createNotificationContent(String uid) { - MessageReference messageReference = createMessageReference(uid); - return createNotificationContent(messageReference); - } - - private NotificationContent createNotificationContent(MessageReference messageReference) { - return new NotificationContent(messageReference, "", "", "", "", false); - } - - private NotificationContent createNotificationContentForStarredMessage() { - MessageReference messageReference = createMessageReference("42"); - return new NotificationContent(messageReference, "", "", "", "", true); - } -} 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..9c1946b573f1f1e4d344f1f25b1f7a3ac40cdbdb 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 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.java b/app/core/src/test/java/com/fsck/k9/notification/RemoveNotificationResultTest.java deleted file mode 100644 index 1a90b0594c3824e1f471afd2a7f4e648534f9f7a..0000000000000000000000000000000000000000 --- a/app/core/src/test/java/com/fsck/k9/notification/RemoveNotificationResultTest.java +++ /dev/null @@ -1,107 +0,0 @@ -package com.fsck.k9.notification; - - -import org.junit.Before; -import org.junit.Test; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - - -public class RemoveNotificationResultTest { - private static final int NOTIFICATION_ID = 23; - - - private NotificationHolder notificationHolder; - - - @Before - public void setUp() throws Exception { - notificationHolder = new NotificationHolder(NOTIFICATION_ID, null); - } - - @Test - public void createNotification_shouldCancelNotification_shouldReturnTrue() throws Exception { - RemoveNotificationResult result = RemoveNotificationResult.createNotification(notificationHolder); - - assertTrue(result.shouldCreateNotification()); - } - - @Test - public void createNotification_getNotificationId_shouldReturnNotificationId() throws Exception { - RemoveNotificationResult result = RemoveNotificationResult.createNotification(notificationHolder); - - assertEquals(NOTIFICATION_ID, result.getNotificationId()); - } - - @Test - public void createNotification_isUnknownNotification_shouldReturnFalse() throws Exception { - RemoveNotificationResult result = RemoveNotificationResult.createNotification(notificationHolder); - - assertFalse(result.isUnknownNotification()); - } - - @Test - public void createNotification_getNotificationHolder_shouldReturnNotificationHolder() throws Exception { - RemoveNotificationResult result = RemoveNotificationResult.createNotification(notificationHolder); - - assertEquals(notificationHolder, result.getNotificationHolder()); - } - - @Test - public void cancelNotification_shouldCancelNotification_shouldReturnFalse() throws Exception { - RemoveNotificationResult result = RemoveNotificationResult.cancelNotification(NOTIFICATION_ID); - - assertFalse(result.shouldCreateNotification()); - } - - @Test - public void cancelNotification_getNotificationId_shouldReturnNotificationId() throws Exception { - RemoveNotificationResult result = RemoveNotificationResult.cancelNotification(NOTIFICATION_ID); - - assertEquals(NOTIFICATION_ID, result.getNotificationId()); - } - - @Test - public void cancelNotification_isUnknownNotification_shouldReturnFalse() throws Exception { - RemoveNotificationResult result = RemoveNotificationResult.cancelNotification(NOTIFICATION_ID); - - assertFalse(result.isUnknownNotification()); - } - - @Test(expected = IllegalStateException.class) - public void cancelNotification_getNotificationHolder_shouldThrowException() throws Exception { - RemoveNotificationResult result = RemoveNotificationResult.cancelNotification(NOTIFICATION_ID); - - result.getNotificationHolder(); - } - - @Test - public void unknownNotification_shouldCancelNotification_shouldReturnFalse() throws Exception { - RemoveNotificationResult result = RemoveNotificationResult.unknownNotification(); - - assertFalse(result.shouldCreateNotification()); - } - - @Test(expected = IllegalStateException.class) - public void unknownNotification_getNotificationId_shouldThrowException() throws Exception { - RemoveNotificationResult result = RemoveNotificationResult.unknownNotification(); - - result.getNotificationId(); - } - - @Test - public void unknownNotification_isUnknownNotification_shouldReturnTrue() throws Exception { - RemoveNotificationResult result = RemoveNotificationResult.unknownNotification(); - - assertTrue(result.isUnknownNotification()); - } - - @Test(expected = IllegalStateException.class) - public void unknownNotification_getNotificationHolder_shouldThrowException() throws Exception { - RemoveNotificationResult result = RemoveNotificationResult.unknownNotification(); - - result.getNotificationHolder(); - } -} 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..707670d110298796bccf52f0b78a224a1ebed891 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/SendFailedNotificationControllerTest.kt @@ -0,0 +1,93 @@ +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 +import org.mockito.kotlin.never + +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 lockScreenNotification = mock() + private val notificationManager = mock() + private val builder = createFakeNotificationBuilder(notification) + private val lockScreenNotificationBuilder = createFakeNotificationBuilder(lockScreenNotification) + private val account = createFakeAccount() + private val contentIntent = mock() + private val notificationId = NotificationIds.getSendFailedNotificationId(account) + private val controller = SendFailedNotificationController( + notificationHelper = createFakeNotificationHelper(notificationManager, builder, lockScreenNotificationBuilder), + 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).setPublicVersion(lockScreenNotification) + verify(lockScreenNotificationBuilder).setContentTitle("Failed to send some messages") + verify(lockScreenNotificationBuilder, never()).setContentText(any()) + verify(lockScreenNotificationBuilder, never()).setTicker(any()) + } + + @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, + notificationBuilder: NotificationCompat.Builder, + lockScreenNotificationBuilder: NotificationCompat.Builder + ): NotificationHelper { + return mock { + on { getContext() } doReturn ApplicationProvider.getApplicationContext() + on { getNotificationManager() } doReturn notificationManager + on { createNotificationBuilder(any(), any()) }.doReturn(notificationBuilder, lockScreenNotificationBuilder) + } + } + + 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/SendFailedNotificationsTest.java b/app/core/src/test/java/com/fsck/k9/notification/SendFailedNotificationsTest.java deleted file mode 100644 index 5e6f617fc0ff09f50b4ca6897ea191d3a39168f1..0000000000000000000000000000000000000000 --- a/app/core/src/test/java/com/fsck/k9/notification/SendFailedNotificationsTest.java +++ /dev/null @@ -1,118 +0,0 @@ -package com.fsck.k9.notification; - - -import android.app.Notification; -import android.app.PendingIntent; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationCompat.Builder; -import androidx.core.app.NotificationManagerCompat; - -import com.fsck.k9.Account; -import com.fsck.k9.testing.MockHelper; -import com.fsck.k9.RobolectricTest; -import org.junit.Before; -import org.junit.Test; -import org.robolectric.RuntimeEnvironment; - -import static org.mockito.Matchers.any; -import static org.mockito.Matchers.anyInt; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - - -public class SendFailedNotificationsTest extends RobolectricTest { - private static final int ACCOUNT_NUMBER = 1; - private static final String ACCOUNT_NAME = "TestAccount"; - - - private NotificationResourceProvider resourceProvider = new TestNotificationResourceProvider(); - private Notification notification; - private NotificationManagerCompat notificationManager; - private Builder builder; - private Account account; - private SendFailedNotifications sendFailedNotifications; - private PendingIntent contentIntent; - private int notificationId; - - - @Before - public void setUp() throws Exception { - notification = createFakeNotification(); - notificationManager = createFakeNotificationManager(); - builder = createFakeNotificationBuilder(notification); - NotificationHelper notificationHelper = createFakeNotificationHelper(notificationManager, builder); - account = createFakeAccount(); - contentIntent = createFakeContentIntent(); - NotificationActionCreator actionBuilder = createActionBuilder(contentIntent); - notificationId = NotificationIds.getSendFailedNotificationId(account); - - sendFailedNotifications = new SendFailedNotifications(notificationHelper, actionBuilder, resourceProvider); - } - - @Test - public void testShowSendFailedNotification() throws Exception { - Exception exception = new Exception(); - - sendFailedNotifications.showSendFailedNotification(account, exception); - - verify(notificationManager).notify(notificationId, notification); - verify(builder).setSmallIcon(resourceProvider.getIconWarning()); - 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 - public void testClearSendFailedNotification() throws Exception { - sendFailedNotifications.clearSendFailedNotification(account); - - verify(notificationManager).cancel(notificationId); - } - - private Notification createFakeNotification() { - return mock(Notification.class); - } - - private NotificationManagerCompat createFakeNotificationManager() { - return mock(NotificationManagerCompat.class); - } - - private Builder createFakeNotificationBuilder(Notification notification) { - Builder builder = MockHelper.mockBuilder(Builder.class); - when(builder.build()).thenReturn(notification); - return builder; - } - - private NotificationHelper createFakeNotificationHelper(NotificationManagerCompat notificationManager, - Builder builder) { - NotificationHelper notificationHelper = mock(NotificationHelper.class); - when(notificationHelper.getContext()).thenReturn(RuntimeEnvironment.application); - when(notificationHelper.getNotificationManager()).thenReturn(notificationManager); - when(notificationHelper.createNotificationBuilder(any(Account.class), - any(NotificationChannelManager.ChannelType.class))) - .thenReturn(builder); - - return notificationHelper; - } - - private Account createFakeAccount() { - Account account = mock(Account.class); - when(account.getAccountNumber()).thenReturn(ACCOUNT_NUMBER); - when(account.getDescription()).thenReturn(ACCOUNT_NAME); - - return account; - } - - private PendingIntent createFakeContentIntent() { - return mock(PendingIntent.class); - } - - private NotificationActionCreator createActionBuilder(PendingIntent contentIntent) { - NotificationActionCreator actionBuilder = mock(NotificationActionCreator.class); - when(actionBuilder.createViewFolderListPendingIntent(any(Account.class), anyInt())).thenReturn(contentIntent); - return actionBuilder; - } -} 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..2d9e92e95d3e7f087a82be440d8234873dbe9aaa --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/SingleMessageNotificationDataCreatorTest.kt @@ -0,0 +1,282 @@ +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, + activeNotifications = listOf( + NotificationHolder( + notificationId = 1, + timestamp = 0, + content = content + ) + ), + inactiveNotifications = emptyList() + ) + } +} 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..216d20c1ae959926152dbfe2ea6482fef51f8ae5 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/SummaryNotificationDataCreatorTest.kt @@ -0,0 +1,278 @@ +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 + +private val TIMESTAMP = 0L + +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, + 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, + 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, + 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, + 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, + 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, + silent = true + ) + + val summaryNotificationData = result as SummaryInboxNotificationData + assertThat(summaryNotificationData.notificationId).isEqualTo( + NotificationIds.getNewMailSummaryNotificationId(account) + ) + assertThat(summaryNotificationData.isSilent).isTrue() + assertThat(summaryNotificationData.timestamp).isEqualTo(TIMESTAMP) + } + + @Test + fun `default actions`() { + val notificationData = createNotificationDataWithMultipleMessages() + + val result = notificationDataCreator.createSummaryNotificationData( + notificationData, + 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, + 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, + 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, + 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, + 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, + 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, + 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 { + val activeNotifications = contentList.mapIndexed { index, content -> + NotificationHolder(notificationId = index, TIMESTAMP, content) + } + + return NotificationData(account, activeNotifications, inactiveNotifications = emptyList()) + } + + @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..417643a1f05a2a094ce376ea0a3de166e456b60b --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/notification/SyncNotificationControllerTest.kt @@ -0,0 +1,153 @@ +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 +import org.mockito.kotlin.never + +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 lockScreenNotification = mock() + private val notificationManager = mock() + private val builder = createFakeNotificationBuilder(notification) + private val lockScreenNotificationBuilder = createFakeNotificationBuilder(lockScreenNotification) + private val account = createFakeAccount() + private val contentIntent = mock() + private val controller = SyncNotificationController( + notificationHelper = createFakeNotificationHelper(notificationManager, builder, lockScreenNotificationBuilder), + 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).setPublicVersion(lockScreenNotification) + verify(lockScreenNotificationBuilder).setContentTitle("Sending mail") + verify(lockScreenNotificationBuilder, never()).setContentText(any()) + verify(lockScreenNotificationBuilder, never()).setTicker(any()) + } + + @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).setPublicVersion(lockScreenNotification) + verify(lockScreenNotificationBuilder).setContentTitle("Checking mail") + verify(lockScreenNotificationBuilder, never()).setContentText(any()) + verify(lockScreenNotificationBuilder, never()).setTicker(any()) + } + + @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).setPublicVersion(lockScreenNotification) + verify(lockScreenNotificationBuilder).setContentTitle("Checking mail") + verify(lockScreenNotificationBuilder, never()).setContentText(any()) + verify(lockScreenNotificationBuilder, never()).setTicker(any()) + } + + @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, + notificationBuilder: NotificationCompat.Builder, + lockScreenNotificationBuilder: NotificationCompat.Builder + ): NotificationHelper { + return mock { + on { getContext() } doReturn ApplicationProvider.getApplicationContext() + on { getNotificationManager() } doReturn notificationManager + on { createNotificationBuilder(any(), any()) }.doReturn(notificationBuilder, lockScreenNotificationBuilder) + 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/notification/SyncNotificationsTest.java b/app/core/src/test/java/com/fsck/k9/notification/SyncNotificationsTest.java deleted file mode 100644 index 4241d17ec1f4117b4b1df63b7dd11eddcada5c5c..0000000000000000000000000000000000000000 --- a/app/core/src/test/java/com/fsck/k9/notification/SyncNotificationsTest.java +++ /dev/null @@ -1,160 +0,0 @@ -package com.fsck.k9.notification; - - -import android.app.Notification; -import android.app.PendingIntent; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationCompat.Builder; -import androidx.core.app.NotificationManagerCompat; - -import com.fsck.k9.Account; -import com.fsck.k9.mailstore.LocalFolder; -import com.fsck.k9.testing.MockHelper; -import com.fsck.k9.RobolectricTest; - -import org.junit.Before; -import org.junit.Test; -import org.robolectric.RuntimeEnvironment; - -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Matchers.any; -import static org.mockito.Matchers.anyInt; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - - -public class SyncNotificationsTest extends RobolectricTest { - private static final int ACCOUNT_NUMBER = 1; - private static final String ACCOUNT_NAME = "TestAccount"; - private static final String FOLDER_SERVER_ID = "INBOX"; - private static final String FOLDER_NAME = "Inbox"; - - - private NotificationResourceProvider resourceProvider = new TestNotificationResourceProvider(); - private Notification notification; - private NotificationManagerCompat notificationManager; - private Builder builder; - private Account account; - private SyncNotifications syncNotifications; - private PendingIntent contentIntent; - - - @Before - public void setUp() throws Exception { - notification = createFakeNotification(); - notificationManager = createFakeNotificationManager(); - builder = createFakeNotificationBuilder(notification); - NotificationHelper notificationHelper = createFakeNotificationHelper(notificationManager, builder); - account = createFakeAccount(); - contentIntent = createFakeContentIntent(); - NotificationActionCreator actionBuilder = createActionBuilder(contentIntent); - - syncNotifications = new SyncNotifications(notificationHelper, actionBuilder, resourceProvider); - } - - @Test - public void testShowSendingNotification() throws Exception { - int notificationId = NotificationIds.getFetchingMailNotificationId(account); - - syncNotifications.showSendingNotification(account); - - verify(notificationManager).notify(notificationId, notification); - verify(builder).setSmallIcon(resourceProvider.getIconSendingMail()); - 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 - public void testClearSendingNotification() throws Exception { - int notificationId = NotificationIds.getFetchingMailNotificationId(account); - - syncNotifications.clearSendingNotification(account); - - verify(notificationManager).cancel(notificationId); - } - - @Test - public void testGetFetchingMailNotificationId() throws Exception { - LocalFolder localFolder = createFakeLocalFolder(); - int notificationId = NotificationIds.getFetchingMailNotificationId(account); - - syncNotifications.showFetchingMailNotification(account, localFolder); - - verify(notificationManager).notify(notificationId, notification); - verify(builder).setSmallIcon(resourceProvider.getIconCheckingMail()); - 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 - public void testClearSendFailedNotification() throws Exception { - int notificationId = NotificationIds.getFetchingMailNotificationId(account); - - syncNotifications.clearFetchingMailNotification(account); - - verify(notificationManager).cancel(notificationId); - } - - - private Notification createFakeNotification() { - return mock(Notification.class); - } - - private NotificationManagerCompat createFakeNotificationManager() { - return mock(NotificationManagerCompat.class); - } - - private Builder createFakeNotificationBuilder(Notification notification) { - Builder builder = MockHelper.mockBuilder(Builder.class); - when(builder.build()).thenReturn(notification); - return builder; - } - - private NotificationHelper createFakeNotificationHelper( - NotificationManagerCompat notificationManager, Builder builder) { - NotificationHelper notificationHelper = mock(NotificationHelper.class); - when(notificationHelper.getContext()).thenReturn(RuntimeEnvironment.application); - when(notificationHelper.getNotificationManager()).thenReturn(notificationManager); - when(notificationHelper.createNotificationBuilder(any(Account.class), - any(NotificationChannelManager.ChannelType.class))) - .thenReturn(builder); - when(notificationHelper.getAccountName(any(Account.class))).thenReturn(ACCOUNT_NAME); - - return notificationHelper; - } - - private Account createFakeAccount() { - Account account = mock(Account.class); - when(account.getAccountNumber()).thenReturn(ACCOUNT_NUMBER); - when(account.getDescription()).thenReturn(ACCOUNT_NAME); - when(account.getOutboxFolderId()).thenReturn(33L); - - return account; - } - - private PendingIntent createFakeContentIntent() { - return mock(PendingIntent.class); - } - - private NotificationActionCreator createActionBuilder(PendingIntent contentIntent) { - NotificationActionCreator actionBuilder = mock(NotificationActionCreator.class); - when(actionBuilder.createViewFolderPendingIntent(eq(account), anyLong(), anyInt())) - .thenReturn(contentIntent); - return actionBuilder; - } - - private LocalFolder createFakeLocalFolder() { - LocalFolder folder = mock(LocalFolder.class); - when(folder.getServerId()).thenReturn(FOLDER_SERVER_ID); - when(folder.getName()).thenReturn(FOLDER_NAME); - return folder; - } -} diff --git a/app/core/src/test/java/com/fsck/k9/notification/TestNotificationResourceProvider.kt b/app/core/src/test/java/com/fsck/k9/notification/TestNotificationResourceProvider.kt index 467d7387dc80ac2557ccdb4749519d52bdcb4457..ab235bbac5e74a93ce10a8ad5045b6df57575a99 100644 --- a/app/core/src/test/java/com/fsck/k9/notification/TestNotificationResourceProvider.kt +++ b/app/core/src/test/java/com/fsck/k9/notification/TestNotificationResourceProvider.kt @@ -26,6 +26,8 @@ class TestNotificationResourceProvider : NotificationResourceProvider { override fun authenticationErrorBody(accountName: String): String = "Authentication failed for $accountName. Update your server settings." + override fun certificateErrorTitle(): String = "Certificate error" + override fun certificateErrorTitle(accountName: String): String = "Certificate error for $accountName" override fun certificateErrorBody(): String = "Check your server settings" diff --git a/app/core/src/test/java/com/fsck/k9/notification/WearNotificationsTest.java b/app/core/src/test/java/com/fsck/k9/notification/WearNotificationsTest.java deleted file mode 100644 index ea830f9b3eb0575a2780745ea4bdc8c7b8f9ba3d..0000000000000000000000000000000000000000 --- a/app/core/src/test/java/com/fsck/k9/notification/WearNotificationsTest.java +++ /dev/null @@ -1,362 +0,0 @@ -package com.fsck.k9.notification; - - -import android.app.Notification; -import android.app.PendingIntent; -import android.content.Context; -import androidx.core.app.NotificationCompat.Action; -import androidx.core.app.NotificationCompat.Builder; -import androidx.core.app.NotificationCompat.Extender; -import androidx.core.app.NotificationCompat.WearableExtender; - -import com.fsck.k9.Account; -import com.fsck.k9.K9; -import com.fsck.k9.K9.NotificationQuickDelete; -import com.fsck.k9.testing.MockHelper; -import com.fsck.k9.RobolectricTest; -import com.fsck.k9.controller.MessageReference; -import com.fsck.k9.controller.MessagingController; - -import org.junit.Before; -import org.junit.Test; -import org.mockito.ArgumentMatcher; -import org.robolectric.RuntimeEnvironment; - -import java.util.ArrayList; - -import static org.junit.Assert.assertEquals; -import static org.mockito.Matchers.any; -import static org.mockito.Matchers.argThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - - -public class WearNotificationsTest extends RobolectricTest { - private static final int ACCOUNT_NUMBER = 42; - private static final String ACCOUNT_NAME = "accountName"; - - private NotificationResourceProvider resourceProvider = new TestNotificationResourceProvider(); - private Account account; - private Builder builder; - private NotificationActionCreator actionCreator; - private TestWearNotifications wearNotifications; - private Notification notification; - - @Before - public void setUp() throws Exception { - account = createAccount(); - notification = createNotification(); - builder = createNotificationBuilder(notification); - actionCreator = createNotificationActionCreator(); - NotificationHelper notificationHelper = createNotificationHelper(RuntimeEnvironment.application, builder); - MessagingController messagingController = createMessagingController(); - - wearNotifications = new TestWearNotifications(notificationHelper, actionCreator, messagingController, - resourceProvider); - } - - @Test - public void testBuildStackedNotification() throws Exception { - disableOptionalActions(); - int notificationIndex = 0; - int notificationId = NotificationIds.getNewMailStackedNotificationId(account, notificationIndex); - MessageReference messageReference = createMessageReference(1); - NotificationContent content = createNotificationContent(messageReference); - NotificationHolder holder = createNotificationHolder(notificationId, content); - PendingIntent replyPendingIntent = createFakePendingIntent(1); - when(actionCreator.createReplyPendingIntent(messageReference, notificationId)).thenReturn(replyPendingIntent); - PendingIntent markAsReadPendingIntent = createFakePendingIntent(2); - when(actionCreator.createMarkMessageAsReadPendingIntent(messageReference, notificationId)) - .thenReturn(markAsReadPendingIntent); - - Notification result = wearNotifications.buildStackedNotification(account, holder); - - assertEquals(notification, result); - verifyExtendWasOnlyCalledOnce(); - verifyAddAction(resourceProvider.getWearIconReplyAll(), "Reply", replyPendingIntent); - verifyAddAction(resourceProvider.getWearIconMarkAsRead(), "Mark Read", markAsReadPendingIntent); - verifyNumberOfActions(2); - } - - @Test - public void testBuildStackedNotificationWithDeleteActionEnabled() throws Exception { - enableDeleteAction(); - int notificationIndex = 0; - int notificationId = NotificationIds.getNewMailStackedNotificationId(account, notificationIndex); - MessageReference messageReference = createMessageReference(1); - NotificationContent content = createNotificationContent(messageReference); - NotificationHolder holder = createNotificationHolder(notificationId, content); - PendingIntent deletePendingIntent = createFakePendingIntent(1); - when(actionCreator.createDeleteMessagePendingIntent(messageReference, notificationId)) - .thenReturn(deletePendingIntent); - - Notification result = wearNotifications.buildStackedNotification(account, holder); - - assertEquals(notification, result); - verifyExtendWasOnlyCalledOnce(); - verifyAddAction(resourceProvider.getWearIconDelete(), "Delete", deletePendingIntent); - } - - @Test - public void testBuildStackedNotificationWithArchiveActionEnabled() throws Exception { - enableArchiveAction(); - int notificationIndex = 0; - int notificationId = NotificationIds.getNewMailStackedNotificationId(account, notificationIndex); - MessageReference messageReference = createMessageReference(1); - NotificationContent content = createNotificationContent(messageReference); - NotificationHolder holder = createNotificationHolder(notificationId, content); - PendingIntent archivePendingIntent = createFakePendingIntent(1); - when(actionCreator.createArchiveMessagePendingIntent(messageReference, notificationId)) - .thenReturn(archivePendingIntent); - - Notification result = wearNotifications.buildStackedNotification(account, holder); - - assertEquals(notification, result); - verifyExtendWasOnlyCalledOnce(); - verifyAddAction(resourceProvider.getWearIconArchive(), "Archive", archivePendingIntent); - } - - @Test - public void testBuildStackedNotificationWithMarkAsSpamActionEnabled() throws Exception { - enableSpamAction(); - int notificationIndex = 0; - int notificationId = NotificationIds.getNewMailStackedNotificationId(account, notificationIndex); - MessageReference messageReference = createMessageReference(1); - NotificationContent content = createNotificationContent(messageReference); - NotificationHolder holder = createNotificationHolder(notificationId, content); - PendingIntent markAsSpamPendingIntent = createFakePendingIntent(1); - when(actionCreator.createMarkMessageAsSpamPendingIntent(messageReference, notificationId)) - .thenReturn(markAsSpamPendingIntent); - - Notification result = wearNotifications.buildStackedNotification(account, holder); - - assertEquals(notification, result); - verifyExtendWasOnlyCalledOnce(); - verifyAddAction(resourceProvider.getWearIconMarkAsSpam(), "Spam", markAsSpamPendingIntent); - } - - @Test - public void testAddSummaryActions() throws Exception { - disableOptionalSummaryActions(); - int notificationId = NotificationIds.getNewMailSummaryNotificationId(account); - ArrayList messageReferences = createMessageReferenceList(); - NotificationData notificationData = createNotificationData(messageReferences); - PendingIntent markAllAsReadPendingIntent = createFakePendingIntent(1); - when(actionCreator.createMarkAllAsReadPendingIntent(account, messageReferences, notificationId)) - .thenReturn(markAllAsReadPendingIntent); - - wearNotifications.addSummaryActions(builder, notificationData); - - verifyExtendWasOnlyCalledOnce(); - verifyAddAction(resourceProvider.getWearIconMarkAsRead(), "Mark All Read", markAllAsReadPendingIntent); - verifyNumberOfActions(1); - } - - @Test - public void testAddSummaryActionsWithDeleteAllActionEnabled() throws Exception { - enableDeleteAction(); - int notificationId = NotificationIds.getNewMailSummaryNotificationId(account); - ArrayList messageReferences = createMessageReferenceList(); - NotificationData notificationData = createNotificationData(messageReferences); - PendingIntent deletePendingIntent = createFakePendingIntent(1); - when(actionCreator.createDeleteAllPendingIntent(account, messageReferences, notificationId)) - .thenReturn(deletePendingIntent); - - wearNotifications.addSummaryActions(builder, notificationData); - - verifyExtendWasOnlyCalledOnce(); - verifyAddAction(resourceProvider.getWearIconDelete(), "Delete All", deletePendingIntent); - } - - @Test - public void testAddSummaryActionsWithArchiveAllActionEnabled() throws Exception { - enableArchiveAction(); - int notificationId = NotificationIds.getNewMailSummaryNotificationId(account); - ArrayList messageReferences = createMessageReferenceList(); - NotificationData notificationData = createNotificationData(messageReferences); - PendingIntent archivePendingIntent = createFakePendingIntent(1); - when(actionCreator.createArchiveAllPendingIntent(account, messageReferences, notificationId)) - .thenReturn(archivePendingIntent); - - wearNotifications.addSummaryActions(builder, notificationData); - - verifyExtendWasOnlyCalledOnce(); - verifyAddAction(resourceProvider.getWearIconArchive(), "Archive All", archivePendingIntent); - } - - private void disableOptionalActions() { - disableDeleteAction(); - disableArchiveAction(); - disableSpamAction(); - } - - private void disableDeleteAction() { - K9.setNotificationQuickDeleteBehaviour(NotificationQuickDelete.NEVER); - } - - private void disableArchiveAction() { - when(account.getArchiveFolderId()).thenReturn(null); - } - - private void disableSpamAction() { - when(account.getSpamFolderId()).thenReturn(null); - } - - private void enableDeleteAction() { - K9.setNotificationQuickDeleteBehaviour(NotificationQuickDelete.ALWAYS); - K9.setConfirmDeleteFromNotification(false); - } - - private void enableArchiveAction() { - when(account.getArchiveFolderId()).thenReturn(22L); - } - - private void enableSpamAction() { - when(account.getSpamFolderId()).thenReturn(11L); - } - - private void disableOptionalSummaryActions() { - disableDeleteAction(); - disableArchiveAction(); - } - - private Builder createNotificationBuilder(Notification notification) { - Builder builder = MockHelper.mockBuilder(Builder.class); - when(builder.build()).thenReturn(notification); - return builder; - } - - private NotificationHelper createNotificationHelper(Context context, Builder builder) { - NotificationHelper notificationHelper = mock(NotificationHelper.class); - when(notificationHelper.createNotificationBuilder(any(Account.class), any(NotificationChannelManager - .ChannelType.class))).thenReturn(builder); - when(notificationHelper.getAccountName(account)).thenReturn(ACCOUNT_NAME); - when(notificationHelper.getContext()).thenReturn(context); - return notificationHelper; - } - - private NotificationActionCreator createNotificationActionCreator() { - return mock(NotificationActionCreator.class); - } - - private Account createAccount() { - Account account = mock(Account.class); - when(account.getAccountNumber()).thenReturn(ACCOUNT_NUMBER); - return account; - } - - private MessagingController createMessagingController() { - MessagingController messagingController = mock(MessagingController.class); - when(messagingController.isMoveCapable(account)).thenReturn(true); - return messagingController; - } - - private NotificationContent createNotificationContent(MessageReference messageReference) { - return new NotificationContent(messageReference, null, null, null, null, false); - } - - private NotificationHolder createNotificationHolder(int notificationId, NotificationContent content) { - return new NotificationHolder(notificationId, content); - } - - private Notification createNotification() { - return mock(Notification.class); - } - - private MessageReference createMessageReference(int number) { - return new MessageReference("account", 1, String.valueOf(number), null); - } - - private PendingIntent createFakePendingIntent(int requestCode) { - return PendingIntent.getActivity(RuntimeEnvironment.application, requestCode, null, 0); - } - - private ArrayList createMessageReferenceList() { - ArrayList messageReferences = new ArrayList<>(); - messageReferences.add(createMessageReference(1)); - messageReferences.add(createMessageReference(2)); - - return messageReferences; - } - - private NotificationData createNotificationData(ArrayList messageReferences) { - NotificationData notificationData = mock(NotificationData.class); - when(notificationData.getAccount()).thenReturn(account); - when(notificationData.getAllMessageReferences()).thenReturn(messageReferences); - return notificationData; - } - - private Builder verifyExtendWasOnlyCalledOnce() { - return verify(builder, times(1)).extend(any(Extender.class)); - } - - private void verifyAddAction(int icon, String title, PendingIntent pendingIntent) { - verify(builder).extend(action(icon, title, pendingIntent)); - } - - private Builder verifyNumberOfActions(int expectedNumberOfActions) { - return verify(builder).extend(numberOfActions(expectedNumberOfActions)); - } - - private WearableExtender action(int icon, String title, PendingIntent pendingIntent) { - return argThat(new ActionMatcher(icon, title, pendingIntent)); - } - - private WearableExtender numberOfActions(int expectedNumberOfActions) { - return argThat(new NumberOfActionsMatcher(expectedNumberOfActions)); - } - - - static class ActionMatcher implements ArgumentMatcher { - private int icon; - private String title; - private PendingIntent pendingIntent; - - public ActionMatcher(int icon, String title, PendingIntent pendingIntent) { - this.icon = icon; - this.title = title; - this.pendingIntent = pendingIntent; - } - - @Override - public boolean matches(WearableExtender argument) { - for (Action action : argument.getActions()) { - if (action.icon == icon && action.title.equals(title) && action.actionIntent == pendingIntent) { - return true; - } - } - - return false; - } - } - - static class NumberOfActionsMatcher implements ArgumentMatcher { - private final int expectedNumberOfActions; - - public NumberOfActionsMatcher(int expectedNumberOfActions) { - this.expectedNumberOfActions = expectedNumberOfActions; - } - - @Override - public boolean matches(WearableExtender argument) { - return argument.getActions().size() == expectedNumberOfActions; - } - } - - static class TestWearNotifications extends WearNotifications { - private final MessagingController messagingController; - - public TestWearNotifications(NotificationHelper notificationHelper, NotificationActionCreator actionCreator, - MessagingController messagingController, NotificationResourceProvider resourceProvider) { - super(notificationHelper, actionCreator, resourceProvider); - this.messagingController = messagingController; - } - - @Override - MessagingController createMessagingController() { - return messagingController; - } - } -} 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 d03f923d247b83459f58d77448aab690dc02b2ff..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,7 +2,7 @@ package com.fsck.k9.preferences import com.fsck.k9.K9RobolectricTest import com.fsck.k9.Preferences -import com.fsck.k9.mailstore.FolderRepositoryManager +import com.fsck.k9.mailstore.FolderRepository import java.io.ByteArrayOutputStream import org.jdom2.Document import org.jdom2.input.SAXBuilder @@ -17,12 +17,12 @@ class SettingsExporterTest : K9RobolectricTest() { private val contentResolver = RuntimeEnvironment.application.contentResolver private val preferences: Preferences by inject() private val folderSettingsProvider: FolderSettingsProvider by inject() - private val folderRepositoryManager: FolderRepositoryManager by inject() + private val folderRepository: FolderRepository by inject() private val settingsExporter = SettingsExporter( contentResolver, preferences, folderSettingsProvider, - folderRepositoryManager + folderRepository ) @Test diff --git a/app/k9mail-jmap/build.gradle b/app/k9mail-jmap/build.gradle index b6a3cf29f34f3bf367f6eeb4ec5dcc53bfc4b2e8..d29aea28ab510dd3757b21624e4c8d6d45daed52 100644 --- a/app/k9mail-jmap/build.gradle +++ b/app/k9mail-jmap/build.gradle @@ -36,7 +36,7 @@ dependencies { testImplementation "com.google.truth:truth:${versions.truth}" testImplementation "org.mockito:mockito-core:${versions.mockito}" testImplementation "org.mockito.kotlin:mockito-kotlin:${versions.mockitoKotlin}" - testImplementation "org.koin:koin-test:${versions.koin}" + testImplementation "io.insert-koin:koin-test-junit4:${versions.koin}" } android { diff --git a/app/k9mail-jmap/src/main/AndroidManifest.xml b/app/k9mail-jmap/src/main/AndroidManifest.xml index 1270236c49a847412c56b7f83b2b8e174ab0f1d9..ec86eaf954d122000760d39dcf0e0821b426487b 100644 --- a/app/k9mail-jmap/src/main/AndroidManifest.xml +++ b/app/k9mail-jmap/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ @@ -31,7 +32,8 @@ android:label="@string/app_name" android:theme="@style/Theme.K9.Startup" android:resizeableActivity="true" - android:allowBackup="false"> + android:allowBackup="false" + tools:replace="android:theme"> - - - - - - - - - diff --git a/app/k9mail-jmap/src/main/java/com/fsck/k9/App.kt b/app/k9mail-jmap/src/main/java/com/fsck/k9/App.kt index ef17a55f3a51651dee49a9d7d0d745f3d0400b0a..1a2c819baba78a4593e3e362ad00d06e28ef8f72 100644 --- a/app/k9mail-jmap/src/main/java/com/fsck/k9/App.kt +++ b/app/k9mail-jmap/src/main/java/com/fsck/k9/App.kt @@ -12,7 +12,7 @@ class App : Application() { private val themeManager: ThemeManager by inject() override fun onCreate() { - Core.earlyInit(this) + Core.earlyInit() super.onCreate() diff --git a/app/k9mail-jmap/src/main/java/com/fsck/k9/backends/WebDavBackendFactory.kt b/app/k9mail-jmap/src/main/java/com/fsck/k9/backends/WebDavBackendFactory.kt index 574b92885f94d78996ee3ae0ed829df57f55da0f..0098b96e468d38b506cb0ea82f36c08c77af6d4b 100644 --- a/app/k9mail-jmap/src/main/java/com/fsck/k9/backends/WebDavBackendFactory.kt +++ b/app/k9mail-jmap/src/main/java/com/fsck/k9/backends/WebDavBackendFactory.kt @@ -8,13 +8,13 @@ import com.fsck.k9.mail.ssl.TrustManagerFactory import com.fsck.k9.mail.store.webdav.DraftsFolderProvider import com.fsck.k9.mail.store.webdav.WebDavStore import com.fsck.k9.mail.transport.WebDavTransport -import com.fsck.k9.mailstore.FolderRepositoryManager +import com.fsck.k9.mailstore.FolderRepository import com.fsck.k9.mailstore.K9BackendStorageFactory class WebDavBackendFactory( private val backendStorageFactory: K9BackendStorageFactory, private val trustManagerFactory: TrustManagerFactory, - private val folderRepositoryManager: FolderRepositoryManager + private val folderRepository: FolderRepository ) : BackendFactory { override fun createBackend(account: Account): Backend { val accountName = account.displayName @@ -27,10 +27,9 @@ class WebDavBackendFactory( } private fun createDraftsFolderProvider(account: Account): DraftsFolderProvider { - val folderRepository = folderRepositoryManager.getFolderRepository(account) return DraftsFolderProvider { val draftsFolderId = account.draftsFolderId ?: error("No Drafts folder configured") - folderRepository.getFolderServerId(draftsFolderId) ?: error("Couldn't find local Drafts folder") + folderRepository.getFolderServerId(account, draftsFolderId) ?: error("Couldn't find local Drafts folder") } } } diff --git a/app/k9mail-jmap/src/main/java/com/fsck/k9/notification/K9NotificationActionCreator.java b/app/k9mail-jmap/src/main/java/com/fsck/k9/notification/K9NotificationActionCreator.java deleted file mode 100644 index 8773ca22af2cc760cb4e0977cbb264fe9ed1d440..0000000000000000000000000000000000000000 --- a/app/k9mail-jmap/src/main/java/com/fsck/k9/notification/K9NotificationActionCreator.java +++ /dev/null @@ -1,232 +0,0 @@ -package com.fsck.k9.notification; - - -import java.util.List; - -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; - -import com.fsck.k9.Account; -import com.fsck.k9.DI; -import com.fsck.k9.K9; -import com.fsck.k9.activity.MessageList; -import com.fsck.k9.ui.notification.DeleteConfirmationActivity; -import com.fsck.k9.activity.compose.MessageActions; -import com.fsck.k9.activity.setup.AccountSetupIncoming; -import com.fsck.k9.activity.setup.AccountSetupOutgoing; -import com.fsck.k9.controller.MessageReference; -import com.fsck.k9.search.AccountSearchConditions; -import com.fsck.k9.search.LocalSearch; -import com.fsck.k9.ui.messagelist.DefaultFolderProvider; - - -/** - * This class contains methods to create the {@link PendingIntent}s for the actions of our notifications. - *

- * Note: - * We need to take special care to ensure the {@code PendingIntent}s are unique as defined in the documentation of - * {@link PendingIntent}. Otherwise selecting a notification action might perform the action on the wrong message. - *

- * We use the notification ID as {@code requestCode} argument to ensure each notification/action pair gets a unique - * {@code PendingIntent}. - */ -class K9NotificationActionCreator implements NotificationActionCreator { - private final Context context; - private final DefaultFolderProvider defaultFolderProvider = DI.get(DefaultFolderProvider.class); - - - public K9NotificationActionCreator(Context context) { - this.context = context; - } - - @Override - public PendingIntent createViewMessagePendingIntent(MessageReference messageReference, int notificationId) { - Intent intent = createMessageViewIntent(messageReference); - return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createViewFolderPendingIntent(Account account, long folderId, int notificationId) { - Intent intent = createMessageListIntent(account, folderId); - return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createViewMessagesPendingIntent(Account account, List messageReferences, - int notificationId) { - - Long folderServerId = getFolderIdOfAllMessages(messageReferences); - - Intent intent; - if (folderServerId == null) { - intent = createMessageListIntent(account); - } else { - intent = createMessageListIntent(account, folderServerId); - } - - return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createViewFolderListPendingIntent(Account account, int notificationId) { - Intent intent = createMessageListIntent(account); - return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createDismissAllMessagesPendingIntent(Account account, int notificationId) { - Intent intent = NotificationActionService.createDismissAllMessagesIntent(context, account); - - return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createDismissMessagePendingIntent(Context context, MessageReference messageReference, - int notificationId) { - - Intent intent = NotificationActionService.createDismissMessageIntent(context, messageReference); - - return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createReplyPendingIntent(MessageReference messageReference, int notificationId) { - Intent intent = MessageActions.getActionReplyIntent(context, messageReference); - - return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createMarkMessageAsReadPendingIntent(MessageReference messageReference, int notificationId) { - Intent intent = NotificationActionService.createMarkMessageAsReadIntent(context, messageReference); - - return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createMarkAllAsReadPendingIntent(Account account, List messageReferences, - int notificationId) { - String accountUuid = account.getUuid(); - Intent intent = NotificationActionService.createMarkAllAsReadIntent(context, accountUuid, messageReferences); - - return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent getEditIncomingServerSettingsIntent(Account account) { - Intent intent = AccountSetupIncoming.intentActionEditIncomingSettings(context, account); - - return PendingIntent.getActivity(context, account.getAccountNumber(), intent, - PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent getEditOutgoingServerSettingsIntent(Account account) { - Intent intent = AccountSetupOutgoing.intentActionEditOutgoingSettings(context, account); - - return PendingIntent.getActivity(context, account.getAccountNumber(), intent, - PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createDeleteMessagePendingIntent(MessageReference messageReference, int notificationId) { - if (K9.isConfirmDeleteFromNotification()) { - return createDeleteConfirmationPendingIntent(messageReference, notificationId); - } else { - return createDeleteServicePendingIntent(messageReference, notificationId); - } - } - - private PendingIntent createDeleteServicePendingIntent(MessageReference messageReference, int notificationId) { - Intent intent = NotificationActionService.createDeleteMessageIntent(context, messageReference); - - return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - private PendingIntent createDeleteConfirmationPendingIntent(MessageReference messageReference, int notificationId) { - Intent intent = DeleteConfirmationActivity.getIntent(context, messageReference); - - return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createDeleteAllPendingIntent(Account account, List messageReferences, - int notificationId) { - if (K9.isConfirmDeleteFromNotification()) { - return getDeleteAllConfirmationPendingIntent(messageReferences, notificationId); - } else { - return getDeleteAllServicePendingIntent(account, messageReferences, notificationId); - } - } - - private PendingIntent getDeleteAllConfirmationPendingIntent(List messageReferences, - int notificationId) { - Intent intent = DeleteConfirmationActivity.getIntent(context, messageReferences); - - return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_CANCEL_CURRENT); - } - - private PendingIntent getDeleteAllServicePendingIntent(Account account, List messageReferences, - int notificationId) { - String accountUuid = account.getUuid(); - Intent intent = NotificationActionService.createDeleteAllMessagesIntent( - context, accountUuid, messageReferences); - - return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createArchiveMessagePendingIntent(MessageReference messageReference, int notificationId) { - Intent intent = NotificationActionService.createArchiveMessageIntent(context, messageReference); - - return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createArchiveAllPendingIntent(Account account, List messageReferences, - int notificationId) { - Intent intent = NotificationActionService.createArchiveAllIntent(context, account, messageReferences); - - return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createMarkMessageAsSpamPendingIntent(MessageReference messageReference, int notificationId) { - Intent intent = NotificationActionService.createMarkMessageAsSpamIntent(context, messageReference); - - return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - private Intent createMessageListIntent(Account account) { - long folderId = defaultFolderProvider.getDefaultFolder(account); - LocalSearch search = new LocalSearch(); - search.addAllowedFolder(folderId); - search.addAccountUuid(account.getUuid()); - return MessageList.intentDisplaySearch(context, search, false, true, true); - } - - private Intent createMessageListIntent(Account account, long folderId) { - LocalSearch search = new LocalSearch(); - search.addAllowedFolder(folderId); - search.addAccountUuid(account.getUuid()); - return MessageList.intentDisplaySearch(context, search, false, true, true); - } - - private Intent createMessageViewIntent(MessageReference message) { - return MessageList.actionDisplayMessageIntent(context, message); - } - - private Long getFolderIdOfAllMessages(List messageReferences) { - MessageReference firstMessage = messageReferences.get(0); - long folderId = firstMessage.getFolderId(); - - for (MessageReference messageReference : messageReferences) { - if (folderId != messageReference.getFolderId()) { - return null; - } - } - - return folderId; - } -} 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 new file mode 100644 index 0000000000000000000000000000000000000000..33b59551f69c3eacc2074074612600aee24abc87 --- /dev/null +++ b/app/k9mail-jmap/src/main/java/com/fsck/k9/notification/K9NotificationActionCreator.kt @@ -0,0 +1,234 @@ +package com.fsck.k9.notification + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import com.fsck.k9.Account +import com.fsck.k9.K9 +import com.fsck.k9.activity.MessageList +import com.fsck.k9.activity.compose.MessageActions +import com.fsck.k9.activity.setup.AccountSetupIncoming +import com.fsck.k9.activity.setup.AccountSetupOutgoing +import com.fsck.k9.controller.MessageReference +import com.fsck.k9.search.LocalSearch +import com.fsck.k9.ui.messagelist.DefaultFolderProvider +import com.fsck.k9.ui.notification.DeleteConfirmationActivity + +/** + * This class contains methods to create the [PendingIntent]s for the actions of our notifications. + * + * **Note:** + * We need to take special care to ensure the `PendingIntent`s are unique as defined in the documentation of + * [PendingIntent]. Otherwise selecting a notification action might perform the action on the wrong message. + * + * We use the notification ID as `requestCode` argument to ensure each notification/action pair gets a unique + * `PendingIntent`. + */ +internal class K9NotificationActionCreator( + private val context: Context, + private val defaultFolderProvider: DefaultFolderProvider +) : NotificationActionCreator { + + override fun createViewMessagePendingIntent( + messageReference: MessageReference, + notificationId: Int + ): PendingIntent { + val intent = createMessageViewIntent(messageReference) + return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createViewFolderPendingIntent(account: Account, folderId: Long, notificationId: Int): PendingIntent { + val intent = createMessageListIntent(account, folderId) + return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createViewMessagesPendingIntent( + account: Account, + messageReferences: List, + notificationId: Int + ): PendingIntent { + val folderServerId = getFolderIdOfAllMessages(messageReferences) + val intent = if (folderServerId != null) { + createMessageListIntent(account, folderServerId) + } else { + createMessageListIntent(account) + } + + return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createViewFolderListPendingIntent(account: Account, notificationId: Int): PendingIntent { + val intent = createMessageListIntent(account) + return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createDismissAllMessagesPendingIntent(account: Account, notificationId: Int): PendingIntent { + val intent = NotificationActionService.createDismissAllMessagesIntent(context, account) + return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createDismissMessagePendingIntent( + messageReference: MessageReference, + notificationId: Int + ): PendingIntent { + val intent = NotificationActionService.createDismissMessageIntent(context, messageReference) + return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createReplyPendingIntent(messageReference: MessageReference, notificationId: Int): PendingIntent { + val intent = MessageActions.getActionReplyIntent(context, messageReference) + return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createMarkMessageAsReadPendingIntent( + messageReference: MessageReference, + notificationId: Int + ): PendingIntent { + val intent = NotificationActionService.createMarkMessageAsReadIntent(context, messageReference) + return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createMarkAllAsReadPendingIntent( + account: Account, + messageReferences: List, + notificationId: Int + ): PendingIntent { + val accountUuid = account.uuid + val intent = NotificationActionService.createMarkAllAsReadIntent(context, accountUuid, messageReferences) + return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun getEditIncomingServerSettingsIntent(account: Account): PendingIntent { + val intent = AccountSetupIncoming.intentActionEditIncomingSettings(context, account) + return PendingIntent.getActivity(context, account.accountNumber, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun getEditOutgoingServerSettingsIntent(account: Account): PendingIntent { + val intent = AccountSetupOutgoing.intentActionEditOutgoingSettings(context, account) + return PendingIntent.getActivity(context, account.accountNumber, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createDeleteMessagePendingIntent( + messageReference: MessageReference, + notificationId: Int + ): PendingIntent { + return if (K9.isConfirmDeleteFromNotification) { + createDeleteConfirmationPendingIntent(messageReference, notificationId) + } else { + createDeleteServicePendingIntent(messageReference, notificationId) + } + } + + private fun createDeleteServicePendingIntent( + messageReference: MessageReference, + notificationId: Int + ): PendingIntent { + val intent = NotificationActionService.createDeleteMessageIntent(context, messageReference) + return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + private fun createDeleteConfirmationPendingIntent( + messageReference: MessageReference, + notificationId: Int + ): PendingIntent { + val intent = DeleteConfirmationActivity.getIntent(context, messageReference) + return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createDeleteAllPendingIntent( + account: Account, + messageReferences: List, + notificationId: Int + ): PendingIntent { + return if (K9.isConfirmDeleteFromNotification) { + getDeleteAllConfirmationPendingIntent(messageReferences, notificationId) + } else { + getDeleteAllServicePendingIntent(account, messageReferences, notificationId) + } + } + + private fun getDeleteAllConfirmationPendingIntent( + messageReferences: List, + notificationId: Int + ): PendingIntent { + val intent = DeleteConfirmationActivity.getIntent(context, messageReferences) + return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_CANCEL_CURRENT) + } + + private fun getDeleteAllServicePendingIntent( + account: Account, + messageReferences: List, + notificationId: Int + ): PendingIntent { + val accountUuid = account.uuid + val intent = NotificationActionService.createDeleteAllMessagesIntent(context, accountUuid, messageReferences) + return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createArchiveMessagePendingIntent( + messageReference: MessageReference, + notificationId: Int + ): PendingIntent { + val intent = NotificationActionService.createArchiveMessageIntent(context, messageReference) + return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createArchiveAllPendingIntent( + account: Account, + messageReferences: List, + notificationId: Int + ): PendingIntent { + val intent = NotificationActionService.createArchiveAllIntent(context, account, messageReferences) + return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createMarkMessageAsSpamPendingIntent( + messageReference: MessageReference, + notificationId: Int + ): PendingIntent { + val intent = NotificationActionService.createMarkMessageAsSpamIntent(context, messageReference) + return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + private fun createMessageListIntent(account: Account): Intent { + val folderId = defaultFolderProvider.getDefaultFolder(account) + val search = LocalSearch().apply { + addAllowedFolder(folderId) + addAccountUuid(account.uuid) + } + + return MessageList.intentDisplaySearch( + context = context, + search = search, + noThreading = false, + newTask = true, + clearTop = true + ) + } + + private fun createMessageListIntent(account: Account, folderId: Long): Intent { + val search = LocalSearch().apply { + addAllowedFolder(folderId) + addAccountUuid(account.uuid) + } + + return MessageList.intentDisplaySearch( + context = context, + search = search, + noThreading = false, + newTask = true, + clearTop = true + ) + } + + private fun createMessageViewIntent(message: MessageReference): Intent { + return MessageList.actionDisplayMessageIntent(context, message) + } + + private fun getFolderIdOfAllMessages(messageReferences: List): Long? { + val firstMessage = messageReferences.first() + val folderId = firstMessage.folderId + + return if (messageReferences.all { it.folderId == folderId }) folderId else null + } +} diff --git a/app/k9mail-jmap/src/main/java/com/fsck/k9/notification/K9NotificationResourceProvider.kt b/app/k9mail-jmap/src/main/java/com/fsck/k9/notification/K9NotificationResourceProvider.kt index 839ce99d9547bfb691e996adf9f1649ff8eb7254..0cea5e4c1112547859725afd900dbf15a9803be0 100644 --- a/app/k9mail-jmap/src/main/java/com/fsck/k9/notification/K9NotificationResourceProvider.kt +++ b/app/k9mail-jmap/src/main/java/com/fsck/k9/notification/K9NotificationResourceProvider.kt @@ -36,6 +36,8 @@ class K9NotificationResourceProvider(private val context: Context) : Notificatio override fun authenticationErrorBody(accountName: String): String = context.getString(R.string.notification_authentication_error_text, accountName) + override fun certificateErrorTitle(): String = context.getString(R.string.notification_certificate_error_public) + override fun certificateErrorTitle(accountName: String): String = context.getString(R.string.notification_certificate_error_title, accountName) diff --git a/app/k9mail-jmap/src/main/java/com/fsck/k9/notification/KoinModule.kt b/app/k9mail-jmap/src/main/java/com/fsck/k9/notification/KoinModule.kt index ce46fad3157de2a7b508da28ee8eace8c62722fa..ac5ee21308f2bdaf0674ce923cc6f055dfc67b8b 100644 --- a/app/k9mail-jmap/src/main/java/com/fsck/k9/notification/KoinModule.kt +++ b/app/k9mail-jmap/src/main/java/com/fsck/k9/notification/KoinModule.kt @@ -3,7 +3,7 @@ package com.fsck.k9.notification import org.koin.dsl.module val notificationModule = module { - single { K9NotificationActionCreator(get()) } + single { K9NotificationActionCreator(context = get(), defaultFolderProvider = get()) } single { K9NotificationResourceProvider(get()) } single { K9NotificationStrategy(get()) } } 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..6c46f88f2dd11e5f409e50869e6782b8775cbca6 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) @@ -37,8 +31,6 @@ class K9CoreResourceProvider(private val context: Context) : CoreResourceProvide override fun replyHeader(sender: String, sentDate: String): String = context.getString(R.string.message_compose_reply_header_fmt_with_date, sentDate, sender) - override fun searchAllMessagesTitle(): String = context.getString(R.string.search_all_messages_title) - override fun searchAllMessagesDetail(): String = context.getString(R.string.search_all_messages_detail) override fun searchUnifiedInboxTitle(): String = context.getString(R.string.integrated_inbox_title) override fun searchUnifiedInboxDetail(): String = context.getString(R.string.integrated_inbox_detail) diff --git a/app/k9mail-jmap/src/main/res/values/themes.xml b/app/k9mail-jmap/src/main/res/values/themes.xml index 4d8dff3ded1dd9bd48fa0bd0e4cf70c132e704ef..700b046c83e1ea06bdcda35a463f0209ef1f4e38 100644 --- a/app/k9mail-jmap/src/main/res/values/themes.xml +++ b/app/k9mail-jmap/src/main/res/values/themes.xml @@ -46,13 +46,14 @@ @drawable/ic_star @drawable/ic_star_border @drawable/ic_opened_envelope - @drawable/ic_envelope + @drawable/ic_mark_new @drawable/ic_magnify_cloud @drawable/ic_plus @drawable/ic_arrow_up_down @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 @@ -163,13 +165,14 @@ @drawable/ic_star @drawable/ic_star_border @drawable/ic_opened_envelope - @drawable/ic_envelope + @drawable/ic_mark_new @drawable/ic_magnify_cloud @drawable/ic_plus @drawable/ic_arrow_up_down @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..14a763039c75cd0067379e4be481cc5b8c52178f 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 @@ -10,7 +10,7 @@ import com.fsck.k9.ui.folders.FolderNameFormatter import com.fsck.k9.ui.helper.SizeFormatter import org.junit.Test import org.junit.runner.RunWith -import org.koin.core.annotation.KoinInternal +import org.koin.core.annotation.KoinInternalApi import org.koin.core.logger.PrintLogger import org.koin.core.parameter.parametersOf import org.koin.java.KoinJavaComponent @@ -31,17 +31,17 @@ class DependencyInjectionTest : AutoCloseKoinTest() { } val autocryptTransferView = mock() - @KoinInternal + @KoinInternalApi @Test fun testDependencyTree() { KoinJavaComponent.getKoin().setupLogger(PrintLogger()) getKoin().checkModules { - create { parametersOf(lifecycleOwner) } + withParameter { lifecycleOwner } create { parametersOf(lifecycleOwner, autocryptTransferView) } - create { parametersOf(RuntimeEnvironment.application) } - create { parametersOf(RuntimeEnvironment.application) } - create { parametersOf(ChangeLogMode.CHANGE_LOG) } + withParameter { RuntimeEnvironment.application } + withParameter { RuntimeEnvironment.application } + withParameter { ChangeLogMode.CHANGE_LOG } } } } diff --git a/app/k9mail/build.gradle b/app/k9mail/build.gradle index d76a16f157b00a851e5d9c17586917d21106d1fc..01cb3adb016ab2a9539beafb1d737cb50a632679 100644 --- a/app/k9mail/build.gradle +++ b/app/k9mail/build.gradle @@ -14,6 +14,7 @@ dependencies { implementation project(":backend:imap") implementation project(":backend:pop3") implementation project(":backend:webdav") + debugImplementation project(":backend:demo") implementation "androidx.appcompat:appcompat:${versions.androidxAppCompat}" implementation "androidx.core:core-ktx:${versions.androidxCore}" @@ -36,7 +37,7 @@ dependencies { testImplementation "com.google.truth:truth:${versions.truth}" testImplementation "org.mockito:mockito-core:${versions.mockito}" testImplementation "org.mockito.kotlin:mockito-kotlin:${versions.mockitoKotlin}" - testImplementation "org.koin:koin-test:${versions.koin}" + testImplementation "io.insert-koin:koin-test-junit4:${versions.koin}" } android { @@ -47,8 +48,8 @@ android { applicationId "foundation.e.mail" testApplicationId "foundation.e.mail.tests" - versionCode 28000 - versionName '5.800' + versionCode 29008 + versionName '5.909-SNAPSHOT' // 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", @@ -89,9 +90,12 @@ android { } } - // Do not abort build if lint finds errors lintOptions { + checkDependencies true + + // Do not abort build if lint finds errors abortOnError false + lintConfig file("$rootProject.projectDir/config/lint/lint.xml") } diff --git a/app/k9mail/src/debug/java/app/k9mail/dev/DebugConfig.kt b/app/k9mail/src/debug/java/app/k9mail/dev/DebugConfig.kt new file mode 100644 index 0000000000000000000000000000000000000000..6d4b73b16bdbc3c4814960f50e22b29470e1af84 --- /dev/null +++ b/app/k9mail/src/debug/java/app/k9mail/dev/DebugConfig.kt @@ -0,0 +1,12 @@ +package app.k9mail.dev + +import org.koin.core.module.Module +import org.koin.core.scope.Scope + +fun Scope.developmentBackends() = mapOf( + "demo" to get() +) + +fun Module.developmentModuleAdditions() { + single { DemoBackendFactory(backendStorageFactory = get()) } +} diff --git a/app/k9mail/src/debug/java/app/k9mail/dev/DemoBackendFactory.kt b/app/k9mail/src/debug/java/app/k9mail/dev/DemoBackendFactory.kt new file mode 100644 index 0000000000000000000000000000000000000000..366fdc61953b8b6d38c591a63ad21c1baf392d82 --- /dev/null +++ b/app/k9mail/src/debug/java/app/k9mail/dev/DemoBackendFactory.kt @@ -0,0 +1,14 @@ +package app.k9mail.dev + +import app.k9mail.backend.demo.DemoBackend +import com.fsck.k9.Account +import com.fsck.k9.backend.BackendFactory +import com.fsck.k9.backend.api.Backend +import com.fsck.k9.mailstore.K9BackendStorageFactory + +class DemoBackendFactory(private val backendStorageFactory: K9BackendStorageFactory) : BackendFactory { + override fun createBackend(account: Account): Backend { + val backendStorage = backendStorageFactory.createBackendStorage(account) + return DemoBackend(backendStorage) + } +} diff --git a/app/k9mail/src/debug/res/values/app-specific.xml b/app/k9mail/src/debug/res/values/app-specific.xml deleted file mode 100644 index 3bc22ec0b41154fa6a091f45e747ff1fa1a7f8ff..0000000000000000000000000000000000000000 --- a/app/k9mail/src/debug/res/values/app-specific.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - com.fsck.k9.debug - diff --git a/app/k9mail/src/main/AndroidManifest.xml b/app/k9mail/src/main/AndroidManifest.xml index 30e30c7025d9834e84ce63ba8c493d0dded8a492..85e38be8ddf6c7f51957ae78afbdcc45e37587e6 100644 --- a/app/k9mail/src/main/AndroidManifest.xml +++ b/app/k9mail/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ @@ -45,12 +46,17 @@ android:name="com.fsck.k9.App" android:allowTaskReparenting="false" android:usesCleartextTraffic="true" + android:networkSecurityConfig="@xml/network_security_config" + android:icon="@drawable/ic_e_launcher" android:roundIcon="@mipmap/icon_e" android:label="@string/app_name" android:theme="@style/Theme.K9.Startup" android:resizeableActivity="true" - android:allowBackup="false"> + android:allowBackup="false" + android:supportsRtl="true" + tools:replace="android:theme" + tools:ignore="UnusedAttribute"> + + - + @@ -199,6 +212,7 @@ android:name=".activity.MessageCompose" android:configChanges="locale" android:enabled="false" + android:exported="true" android:label="@string/app_name"> @@ -237,7 +251,8 @@ android:name=".activity.Search" android:configChanges="locale" android:label="@string/search_action" - android:uiOptions="splitActionBarWhenNarrow"> + android:uiOptions="splitActionBarWhenNarrow" + android:exported="true"> @@ -250,14 +265,17 @@ + android:label="@string/shortcuts_title" + android:exported="true"> - + @@ -342,7 +360,8 @@ + android:label="@string/mail_list_widget_text" + android:exported="true"> @@ -376,7 +395,8 @@ + android:permission="android.permission.BIND_CHOOSER_TARGET_SERVICE" + android:exported="true"> diff --git a/app/k9mail/src/main/java/com/fsck/k9/App.kt b/app/k9mail/src/main/java/com/fsck/k9/App.kt index 79646b9fe14f1206a3155ae0fd43a112d90f2b25..68991190e99caab3e54bce25b5d9c1ca037e3f6e 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/App.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/App.kt @@ -29,7 +29,7 @@ class App : Application() { private var appLanguageManagerInitialized = false override fun onCreate() { - Core.earlyInit(this) + Core.earlyInit() super.onCreate() diff --git a/app/k9mail/src/main/java/com/fsck/k9/backends/KoinModule.kt b/app/k9mail/src/main/java/com/fsck/k9/backends/KoinModule.kt index bc2269051ef4726bfcfa77c3a0a93203584a41c2..1b947b8ded21e02b921ba3305c4967a5552a0914 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/backends/KoinModule.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/backends/KoinModule.kt @@ -1,5 +1,7 @@ package com.fsck.k9.backends +import app.k9mail.dev.developmentBackends +import app.k9mail.dev.developmentModuleAdditions import com.fsck.k9.backend.BackendManager import com.fsck.k9.backend.imap.BackendIdleRefreshManager import com.fsck.k9.backend.imap.SystemAlarmManager @@ -13,7 +15,7 @@ val backendsModule = module { "imap" to get(), "pop3" to get(), "webdav" to get() - ) + ) + developmentBackends() ) } single { @@ -31,4 +33,6 @@ val backendsModule = module { single { BackendIdleRefreshManager(alarmManager = get()) } single { Pop3BackendFactory(get(), get()) } single { WebDavBackendFactory(get(), get(), get()) } + + developmentModuleAdditions() } diff --git a/app/k9mail/src/main/java/com/fsck/k9/backends/WebDavBackendFactory.kt b/app/k9mail/src/main/java/com/fsck/k9/backends/WebDavBackendFactory.kt index 574b92885f94d78996ee3ae0ed829df57f55da0f..0098b96e468d38b506cb0ea82f36c08c77af6d4b 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/backends/WebDavBackendFactory.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/backends/WebDavBackendFactory.kt @@ -8,13 +8,13 @@ import com.fsck.k9.mail.ssl.TrustManagerFactory import com.fsck.k9.mail.store.webdav.DraftsFolderProvider import com.fsck.k9.mail.store.webdav.WebDavStore import com.fsck.k9.mail.transport.WebDavTransport -import com.fsck.k9.mailstore.FolderRepositoryManager +import com.fsck.k9.mailstore.FolderRepository import com.fsck.k9.mailstore.K9BackendStorageFactory class WebDavBackendFactory( private val backendStorageFactory: K9BackendStorageFactory, private val trustManagerFactory: TrustManagerFactory, - private val folderRepositoryManager: FolderRepositoryManager + private val folderRepository: FolderRepository ) : BackendFactory { override fun createBackend(account: Account): Backend { val accountName = account.displayName @@ -27,10 +27,9 @@ class WebDavBackendFactory( } private fun createDraftsFolderProvider(account: Account): DraftsFolderProvider { - val folderRepository = folderRepositoryManager.getFolderRepository(account) return DraftsFolderProvider { val draftsFolderId = account.draftsFolderId ?: error("No Drafts folder configured") - folderRepository.getFolderServerId(draftsFolderId) ?: error("Couldn't find local Drafts folder") + folderRepository.getFolderServerId(account, draftsFolderId) ?: error("Couldn't find local Drafts folder") } } } diff --git a/app/k9mail/src/main/java/com/fsck/k9/external/MessageProvider.java b/app/k9mail/src/main/java/com/fsck/k9/external/MessageProvider.java index d3929ff5a16019cd2e502fc75414e1fa79079b7c..97fa09796f8799eeab43b3af274b0b7fb618f456 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/external/MessageProvider.java +++ b/app/k9mail/src/main/java/com/fsck/k9/external/MessageProvider.java @@ -152,10 +152,6 @@ public class MessageProvider extends ContentProvider { for (Account account : Preferences.getPreferences(getContext()).getAccounts()) { if (account.getAccountNumber() == accountId) { myAccount = account; - if (!account.isAvailable(getContext())) { - Timber.w("not deleting messages because account is unavailable at the moment"); - return 0; - } } } @@ -164,7 +160,7 @@ public class MessageProvider extends ContentProvider { } if (myAccount != null) { - MessageReference messageReference = new MessageReference(myAccount.getUuid(), folderId, msgUid, null); + MessageReference messageReference = new MessageReference(myAccount.getUuid(), folderId, msgUid); MessagingController controller = MessagingController.getInstance(getContext()); controller.deleteMessage(messageReference); } @@ -661,7 +657,7 @@ public class MessageProvider extends ContentProvider { Context context = getContext(); MessagingController controller = MessagingController.getInstance(context); - Collection accounts = Preferences.getPreferences(context).getAvailableAccounts(); + Collection accounts = Preferences.getPreferences(context).getAccounts(); for (Account account : accounts) { if (account.getAccountNumber() == accountNumber) { diff --git a/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationActionCreator.java b/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationActionCreator.java deleted file mode 100644 index f0d8b471c820290ed17d10682d56a578e064640a..0000000000000000000000000000000000000000 --- a/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationActionCreator.java +++ /dev/null @@ -1,232 +0,0 @@ -package com.fsck.k9.notification; - - -import java.util.List; - -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; - -import com.fsck.k9.Account; -import com.fsck.k9.DI; -import com.fsck.k9.K9; -import com.fsck.k9.activity.MessageList; -import com.fsck.k9.controller.MessageReference; -import com.fsck.k9.ui.notification.DeleteConfirmationActivity; -import com.fsck.k9.activity.compose.MessageActions; -import com.fsck.k9.activity.setup.AccountSetupIncoming; -import com.fsck.k9.activity.setup.AccountSetupOutgoing; -import com.fsck.k9.search.AccountSearchConditions; -import com.fsck.k9.search.LocalSearch; -import com.fsck.k9.ui.messagelist.DefaultFolderProvider; - - -/** - * This class contains methods to create the {@link PendingIntent}s for the actions of our notifications. - *

- * Note: - * We need to take special care to ensure the {@code PendingIntent}s are unique as defined in the documentation of - * {@link PendingIntent}. Otherwise selecting a notification action might perform the action on the wrong message. - *

- * We use the notification ID as {@code requestCode} argument to ensure each notification/action pair gets a unique - * {@code PendingIntent}. - */ -class K9NotificationActionCreator implements NotificationActionCreator { - private final Context context; - private final DefaultFolderProvider defaultFolderProvider = DI.get(DefaultFolderProvider.class); - - - public K9NotificationActionCreator(Context context) { - this.context = context; - } - - @Override - public PendingIntent createViewMessagePendingIntent(MessageReference messageReference, int notificationId) { - Intent intent = createMessageViewIntent(messageReference); - return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createViewFolderPendingIntent(Account account, long folderId, int notificationId) { - Intent intent = createMessageListIntent(account, folderId); - return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createViewMessagesPendingIntent(Account account, List messageReferences, - int notificationId) { - - Long folderServerId = getFolderIdOfAllMessages(messageReferences); - - Intent intent; - if (folderServerId == null) { - intent = createMessageListIntent(account); - } else { - intent = createMessageListIntent(account, folderServerId); - } - - return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createViewFolderListPendingIntent(Account account, int notificationId) { - Intent intent = createMessageListIntent(account); - return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createDismissAllMessagesPendingIntent(Account account, int notificationId) { - Intent intent = NotificationActionService.createDismissAllMessagesIntent(context, account); - - return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createDismissMessagePendingIntent(Context context, MessageReference messageReference, - int notificationId) { - - Intent intent = NotificationActionService.createDismissMessageIntent(context, messageReference); - - return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createReplyPendingIntent(MessageReference messageReference, int notificationId) { - Intent intent = MessageActions.getActionReplyIntent(context, messageReference); - - return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createMarkMessageAsReadPendingIntent(MessageReference messageReference, int notificationId) { - Intent intent = NotificationActionService.createMarkMessageAsReadIntent(context, messageReference); - - return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createMarkAllAsReadPendingIntent(Account account, List messageReferences, - int notificationId) { - String accountUuid = account.getUuid(); - Intent intent = NotificationActionService.createMarkAllAsReadIntent(context, accountUuid, messageReferences); - - return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent getEditIncomingServerSettingsIntent(Account account) { - Intent intent = AccountSetupIncoming.intentActionEditIncomingSettings(context, account); - - return PendingIntent.getActivity(context, account.getAccountNumber(), intent, - PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent getEditOutgoingServerSettingsIntent(Account account) { - Intent intent = AccountSetupOutgoing.intentActionEditOutgoingSettings(context, account); - - return PendingIntent.getActivity(context, account.getAccountNumber(), intent, - PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createDeleteMessagePendingIntent(MessageReference messageReference, int notificationId) { - if (K9.isConfirmDeleteFromNotification()) { - return createDeleteConfirmationPendingIntent(messageReference, notificationId); - } else { - return createDeleteServicePendingIntent(messageReference, notificationId); - } - } - - private PendingIntent createDeleteServicePendingIntent(MessageReference messageReference, int notificationId) { - Intent intent = NotificationActionService.createDeleteMessageIntent(context, messageReference); - - return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - private PendingIntent createDeleteConfirmationPendingIntent(MessageReference messageReference, int notificationId) { - Intent intent = DeleteConfirmationActivity.getIntent(context, messageReference); - - return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createDeleteAllPendingIntent(Account account, List messageReferences, - int notificationId) { - if (K9.isConfirmDeleteFromNotification()) { - return getDeleteAllConfirmationPendingIntent(messageReferences, notificationId); - } else { - return getDeleteAllServicePendingIntent(account, messageReferences, notificationId); - } - } - - private PendingIntent getDeleteAllConfirmationPendingIntent(List messageReferences, - int notificationId) { - Intent intent = DeleteConfirmationActivity.getIntent(context, messageReferences); - - return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_CANCEL_CURRENT); - } - - private PendingIntent getDeleteAllServicePendingIntent(Account account, List messageReferences, - int notificationId) { - String accountUuid = account.getUuid(); - Intent intent = NotificationActionService.createDeleteAllMessagesIntent( - context, accountUuid, messageReferences); - - return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createArchiveMessagePendingIntent(MessageReference messageReference, int notificationId) { - Intent intent = NotificationActionService.createArchiveMessageIntent(context, messageReference); - - return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createArchiveAllPendingIntent(Account account, List messageReferences, - int notificationId) { - Intent intent = NotificationActionService.createArchiveAllIntent(context, account, messageReferences); - - return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - @Override - public PendingIntent createMarkMessageAsSpamPendingIntent(MessageReference messageReference, int notificationId) { - Intent intent = NotificationActionService.createMarkMessageAsSpamIntent(context, messageReference); - - return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - private Intent createMessageListIntent(Account account) { - long folderId = defaultFolderProvider.getDefaultFolder(account); - LocalSearch search = new LocalSearch(); - search.addAllowedFolder(folderId); - search.addAccountUuid(account.getUuid()); - return MessageList.intentDisplaySearch(context, search, false, true, true); - } - - private Intent createMessageListIntent(Account account, long folderId) { - LocalSearch search = new LocalSearch(); - search.addAllowedFolder(folderId); - search.addAccountUuid(account.getUuid()); - return MessageList.intentDisplaySearch(context, search, false, true, true); - } - - private Intent createMessageViewIntent(MessageReference message) { - return MessageList.actionDisplayMessageIntent(context, message); - } - - private Long getFolderIdOfAllMessages(List messageReferences) { - MessageReference firstMessage = messageReferences.get(0); - long folderId = firstMessage.getFolderId(); - - for (MessageReference messageReference : messageReferences) { - if (folderId != messageReference.getFolderId()) { - return null; - } - } - - return folderId; - } -} diff --git a/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationActionCreator.kt b/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationActionCreator.kt new file mode 100644 index 0000000000000000000000000000000000000000..d03ca29a489b37dfb785c4c0307e8394380f95b5 --- /dev/null +++ b/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationActionCreator.kt @@ -0,0 +1,256 @@ +package com.fsck.k9.notification + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import com.fsck.k9.Account +import com.fsck.k9.K9 +import com.fsck.k9.activity.MessageList +import com.fsck.k9.activity.compose.MessageActions +import com.fsck.k9.activity.setup.AccountSetupIncoming +import com.fsck.k9.activity.setup.AccountSetupOutgoing +import com.fsck.k9.controller.MessageReference +import com.fsck.k9.mailstore.MessageStoreManager +import com.fsck.k9.search.LocalSearch +import com.fsck.k9.ui.messagelist.DefaultFolderProvider +import com.fsck.k9.ui.notification.DeleteConfirmationActivity + +/** + * This class contains methods to create the [PendingIntent]s for the actions of our notifications. + * + * **Note:** + * We need to take special care to ensure the `PendingIntent`s are unique as defined in the documentation of + * [PendingIntent]. Otherwise selecting a notification action might perform the action on the wrong message. + * + * We use the notification ID as `requestCode` argument to ensure each notification/action pair gets a unique + * `PendingIntent`. + */ +internal class K9NotificationActionCreator( + private val context: Context, + private val defaultFolderProvider: DefaultFolderProvider, + private val messageStoreManager: MessageStoreManager +) : NotificationActionCreator { + + override fun createViewMessagePendingIntent( + messageReference: MessageReference, + notificationId: Int + ): PendingIntent { + val openInUnifiedInbox = K9.isShowUnifiedInbox && isIncludedInUnifiedInbox(messageReference) + val intent = createMessageViewIntent(messageReference, openInUnifiedInbox) + + return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createViewFolderPendingIntent(account: Account, folderId: Long, notificationId: Int): PendingIntent { + val intent = createMessageListIntent(account, folderId) + return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createViewMessagesPendingIntent( + account: Account, + messageReferences: List, + notificationId: Int + ): PendingIntent { + val folderIds = extractFolderIds(messageReferences) + + val intent = if (K9.isShowUnifiedInbox && areAllIncludedInUnifiedInbox(account, folderIds)) { + createUnifiedInboxIntent(account) + } else if (folderIds.size == 1) { + createMessageListIntent(account, folderIds.first()) + } else { + createNewMessagesIntent(account) + } + + return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createViewFolderListPendingIntent(account: Account, notificationId: Int): PendingIntent { + val intent = createMessageListIntent(account) + return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createDismissAllMessagesPendingIntent(account: Account, notificationId: Int): PendingIntent { + val intent = NotificationActionService.createDismissAllMessagesIntent(context, account) + return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createDismissMessagePendingIntent( + messageReference: MessageReference, + notificationId: Int + ): PendingIntent { + val intent = NotificationActionService.createDismissMessageIntent(context, messageReference) + return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createReplyPendingIntent(messageReference: MessageReference, notificationId: Int): PendingIntent { + val intent = MessageActions.getActionReplyIntent(context, messageReference) + return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createMarkMessageAsReadPendingIntent( + messageReference: MessageReference, + notificationId: Int + ): PendingIntent { + val intent = NotificationActionService.createMarkMessageAsReadIntent(context, messageReference) + return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createMarkAllAsReadPendingIntent( + account: Account, + messageReferences: List, + notificationId: Int + ): PendingIntent { + val accountUuid = account.uuid + val intent = NotificationActionService.createMarkAllAsReadIntent(context, accountUuid, messageReferences) + return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun getEditIncomingServerSettingsIntent(account: Account): PendingIntent { + val intent = AccountSetupIncoming.intentActionEditIncomingSettings(context, account) + return PendingIntent.getActivity(context, account.accountNumber, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun getEditOutgoingServerSettingsIntent(account: Account): PendingIntent { + val intent = AccountSetupOutgoing.intentActionEditOutgoingSettings(context, account) + return PendingIntent.getActivity(context, account.accountNumber, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createDeleteMessagePendingIntent( + messageReference: MessageReference, + notificationId: Int + ): PendingIntent { + return if (K9.isConfirmDeleteFromNotification) { + createDeleteConfirmationPendingIntent(messageReference, notificationId) + } else { + createDeleteServicePendingIntent(messageReference, notificationId) + } + } + + private fun createDeleteServicePendingIntent( + messageReference: MessageReference, + notificationId: Int + ): PendingIntent { + val intent = NotificationActionService.createDeleteMessageIntent(context, messageReference) + return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + private fun createDeleteConfirmationPendingIntent( + messageReference: MessageReference, + notificationId: Int + ): PendingIntent { + val intent = DeleteConfirmationActivity.getIntent(context, messageReference) + return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createDeleteAllPendingIntent( + account: Account, + messageReferences: List, + notificationId: Int + ): PendingIntent { + return if (K9.isConfirmDeleteFromNotification) { + getDeleteAllConfirmationPendingIntent(messageReferences, notificationId) + } else { + getDeleteAllServicePendingIntent(account, messageReferences, notificationId) + } + } + + private fun getDeleteAllConfirmationPendingIntent( + messageReferences: List, + notificationId: Int + ): PendingIntent { + val intent = DeleteConfirmationActivity.getIntent(context, messageReferences) + return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_CANCEL_CURRENT) + } + + private fun getDeleteAllServicePendingIntent( + account: Account, + messageReferences: List, + notificationId: Int + ): PendingIntent { + val accountUuid = account.uuid + val intent = NotificationActionService.createDeleteAllMessagesIntent(context, accountUuid, messageReferences) + return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createArchiveMessagePendingIntent( + messageReference: MessageReference, + notificationId: Int + ): PendingIntent { + val intent = NotificationActionService.createArchiveMessageIntent(context, messageReference) + return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createArchiveAllPendingIntent( + account: Account, + messageReferences: List, + notificationId: Int + ): PendingIntent { + val intent = NotificationActionService.createArchiveAllIntent(context, account, messageReferences) + return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + override fun createMarkMessageAsSpamPendingIntent( + messageReference: MessageReference, + notificationId: Int + ): PendingIntent { + val intent = NotificationActionService.createMarkMessageAsSpamIntent(context, messageReference) + return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + private fun createMessageListIntent(account: Account): Intent { + val folderId = defaultFolderProvider.getDefaultFolder(account) + val search = LocalSearch().apply { + addAllowedFolder(folderId) + addAccountUuid(account.uuid) + } + + return MessageList.intentDisplaySearch( + context = context, + search = search, + noThreading = false, + newTask = true, + clearTop = true + ) + } + + private fun createMessageListIntent(account: Account, folderId: Long): Intent { + val search = LocalSearch().apply { + addAllowedFolder(folderId) + addAccountUuid(account.uuid) + } + + return MessageList.intentDisplaySearch( + context = context, + search = search, + noThreading = false, + newTask = true, + clearTop = true + ) + } + + private fun createMessageViewIntent(message: MessageReference, openInUnifiedInbox: Boolean): Intent { + return MessageList.actionDisplayMessageIntent(context, message, openInUnifiedInbox) + } + + private fun createUnifiedInboxIntent(account: Account): Intent { + return MessageList.createUnifiedInboxIntent(context, account) + } + + private fun createNewMessagesIntent(account: Account): Intent { + return MessageList.createNewMessagesIntent(context, account) + } + + private fun extractFolderIds(messageReferences: List): Set { + return messageReferences.asSequence().map { it.folderId }.toSet() + } + + private fun areAllIncludedInUnifiedInbox(account: Account, folderIds: Collection): Boolean { + val messageStore = messageStoreManager.getMessageStore(account) + return messageStore.areAllIncludedInUnifiedInbox(folderIds) + } + + private fun isIncludedInUnifiedInbox(messageReference: MessageReference): Boolean { + val messageStore = messageStoreManager.getMessageStore(messageReference.accountUuid) + return messageStore.areAllIncludedInUnifiedInbox(listOf(messageReference.folderId)) + } +} diff --git a/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationResourceProvider.kt b/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationResourceProvider.kt index cb7b2a4455b82bcfdd143824e3f8d011ff2a94f5..5304232375691c51aea6ec314d9515c96231b627 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationResourceProvider.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationResourceProvider.kt @@ -36,6 +36,8 @@ class K9NotificationResourceProvider(private val context: Context) : Notificatio override fun authenticationErrorBody(accountName: String): String = context.getString(R.string.notification_authentication_error_text, accountName) + override fun certificateErrorTitle(): String = context.getString(R.string.notification_certificate_error_public) + override fun certificateErrorTitle(accountName: String): String = context.getString(R.string.notification_certificate_error_title, accountName) diff --git a/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationStrategy.kt b/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationStrategy.kt index 254c4abe7b44c4327f43f2cefac553a67f95b985..37630d5e9d7ce53d36d3d8886f5353d097bee02b 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationStrategy.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationStrategy.kt @@ -4,6 +4,8 @@ import com.fsck.k9.Account import com.fsck.k9.K9 import com.fsck.k9.helper.Contacts import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.K9MailLib +import com.fsck.k9.mail.Message import com.fsck.k9.mailstore.LocalFolder import com.fsck.k9.mailstore.LocalFolder.isModeMismatch import com.fsck.k9.mailstore.LocalMessage @@ -80,6 +82,11 @@ class K9NotificationStrategy(private val contacts: Contacts) : NotificationStrat return false } + if (account.isIgnoreChatMessages && message.isChatMessage) { + Timber.v("No notification: Notifications for chat messages are disabled") + return false + } + if (!account.isNotifySelfNewMail && account.isAnIdentity(message.from)) { Timber.v("No notification: Notifications for messages from yourself are disabled") return false @@ -92,4 +99,7 @@ class K9NotificationStrategy(private val contacts: Contacts) : NotificationStrat return true } + + private val Message.isChatMessage: Boolean + get() = getHeader(K9MailLib.CHAT_HEADER).isNotEmpty() } diff --git a/app/k9mail/src/main/java/com/fsck/k9/notification/KoinModule.kt b/app/k9mail/src/main/java/com/fsck/k9/notification/KoinModule.kt index ce46fad3157de2a7b508da28ee8eace8c62722fa..d86c64d8a04d22e28b1c814953833a65807a4cd5 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/notification/KoinModule.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/notification/KoinModule.kt @@ -3,7 +3,9 @@ package com.fsck.k9.notification import org.koin.dsl.module val notificationModule = module { - single { K9NotificationActionCreator(get()) } + single { + K9NotificationActionCreator(context = get(), defaultFolderProvider = get(), messageStoreManager = get()) + } single { K9NotificationResourceProvider(get()) } single { K9NotificationStrategy(get()) } } diff --git a/app/k9mail/src/main/java/com/fsck/k9/resources/K9CoreResourceProvider.kt b/app/k9mail/src/main/java/com/fsck/k9/resources/K9CoreResourceProvider.kt index cd2603adbeab4494bb0195ada5097230f40803f3..270736c3ee3de5be8647f454d8226076baf14974 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/resources/K9CoreResourceProvider.kt +++ b/app/k9mail/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) @@ -37,8 +31,6 @@ class K9CoreResourceProvider(private val context: Context) : CoreResourceProvide override fun replyHeader(sender: String, sentDate: String): String = context.getString(R.string.message_compose_reply_header_fmt_with_date, sentDate, sender) - override fun searchAllMessagesTitle(): String = context.getString(R.string.search_all_messages_title) - override fun searchAllMessagesDetail(): String = context.getString(R.string.search_all_messages_detail) override fun searchUnifiedInboxTitle(): String = context.getString(R.string.integrated_inbox_title) override fun searchUnifiedInboxDetail(): String = context.getString(R.string.integrated_inbox_detail) diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListRemoteViewFactory.java b/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListRemoteViewFactory.java index f159a43833c510c53c2b36daeb226131f2f6aa78..3d9af2ea24dbe48a3d52cc3ab8a9c98cd78c540d 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListRemoteViewFactory.java +++ b/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListRemoteViewFactory.java @@ -135,6 +135,7 @@ public class MessageListRemoteViewFactory implements RemoteViewsService.RemoteVi } Intent intent = new Intent(); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); intent.setData(item.uri); remoteView.setOnClickFillInIntent(R.id.mail_list_item, intent); diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/unread/KoinModule.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/KoinModule.kt index 1adad66c0f8538ddd7c13e4ee61f1ca482f92ac1..1cae6a4fde663232db1c709eca35535b489733c2 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/widget/unread/KoinModule.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/KoinModule.kt @@ -10,11 +10,11 @@ val unreadWidgetModule = module { preferences = get(), messagingController = get(), defaultFolderProvider = get(), - folderRepositoryManager = get(), + folderRepository = get(), folderNameFormatterFactory = get() ) } single { UnreadWidgetUpdater(context = get()) } single { UnreadWidgetUpdateListener(unreadWidgetUpdater = get()) } - single { UnreadWidgetMigrations(accountRepository = get(), folderRepositoryManager = get()) } + single { UnreadWidgetMigrations(accountRepository = get(), folderRepository = get()) } } diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetConfigurationFragment.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetConfigurationFragment.kt index 180c8e434858f34cce58903b39c0a8e856f658df..57dc0fc58086cf02123a5b41f81f90416d6b3d17 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetConfigurationFragment.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetConfigurationFragment.kt @@ -58,6 +58,7 @@ class UnreadWidgetConfigurationFragment : PreferenceFragmentCompat() { unreadFolder.onPreferenceClickListener = Preference.OnPreferenceClickListener { val intent = ChooseFolderActivity.buildLaunchIntent( context = requireContext(), + action = ChooseFolderActivity.Action.CHOOSE, accountUuid = selectedAccountUuid!!, showDisplayableOnly = true ) diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetDataProvider.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetDataProvider.kt index 1de02427e0702ec6fab5751a4a6dcb66e0de454d..b2b63a82e5f452960e75070045999bb3f1b5b6a0 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetDataProvider.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetDataProvider.kt @@ -7,7 +7,7 @@ import com.fsck.k9.Preferences import com.fsck.k9.R import com.fsck.k9.activity.MessageList import com.fsck.k9.controller.MessagingController -import com.fsck.k9.mailstore.FolderRepositoryManager +import com.fsck.k9.mailstore.FolderRepository import com.fsck.k9.search.LocalSearch import com.fsck.k9.search.SearchAccount import com.fsck.k9.ui.folders.FolderNameFormatterFactory @@ -19,7 +19,7 @@ class UnreadWidgetDataProvider( private val preferences: Preferences, private val messagingController: MessagingController, private val defaultFolderProvider: DefaultFolderProvider, - private val folderRepositoryManager: FolderRepositoryManager, + private val folderRepository: FolderRepository, private val folderNameFormatterFactory: FolderNameFormatterFactory ) { fun loadUnreadWidgetData(configuration: UnreadWidgetConfiguration): UnreadWidgetData? = with(configuration) { @@ -78,8 +78,7 @@ class UnreadWidgetDataProvider( } private fun getFolderDisplayName(account: Account, folderId: Long): String { - val folderRepository = folderRepositoryManager.getFolderRepository(account) - val folder = folderRepository.getFolder(folderId) + val folder = folderRepository.getFolder(account, folderId) return if (folder != null) { val folderNameFormatter = folderNameFormatterFactory.create(context) folderNameFormatter.displayName(folder) diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetMigrations.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetMigrations.kt index d2b518dcf0f412a94b610d4eb421c4634923e2c6..4221ccef743631a452565beb8c59efe6fddb9b01 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetMigrations.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetMigrations.kt @@ -3,13 +3,13 @@ package com.fsck.k9.widget.unread import android.content.SharedPreferences import androidx.core.content.edit import com.fsck.k9.Preferences -import com.fsck.k9.mailstore.FolderRepositoryManager +import com.fsck.k9.mailstore.FolderRepository import com.fsck.k9.widget.unread.UnreadWidgetRepository.Companion.PREFS_VERSION import com.fsck.k9.widget.unread.UnreadWidgetRepository.Companion.PREF_VERSION_KEY internal class UnreadWidgetMigrations( private val accountRepository: Preferences, - private val folderRepositoryManager: FolderRepositoryManager + private val folderRepository: FolderRepository ) { fun upgradePreferences(preferences: SharedPreferences, version: Int) { if (version < 2) rewriteFolderNameToFolderId(preferences) @@ -33,8 +33,7 @@ internal class UnreadWidgetMigrations( val folderServerId = preferences.getString("unread_widget.$widgetId.folder_name", null) if (folderServerId != null) { - val folderRepository = folderRepositoryManager.getFolderRepository(account) - val folderId = folderRepository.getFolderId(folderServerId) + val folderId = folderRepository.getFolderId(account, folderServerId) putString("unread_widget.$widgetId.folder_id", folderId?.toString()) } diff --git a/app/k9mail/src/main/res/layout/activity_unread_widget_configuration.xml b/app/k9mail/src/main/res/layout/activity_unread_widget_configuration.xml index 6093c57ecc44fcd59b0a0ff9e0d7acbbbae3036e..aac4c66e5a7e89baf26c9efa58b8986de14f17f9 100644 --- a/app/k9mail/src/main/res/layout/activity_unread_widget_configuration.xml +++ b/app/k9mail/src/main/res/layout/activity_unread_widget_configuration.xml @@ -1,8 +1,10 @@ + android:orientation="vertical" + tools:context=".widget.unread.UnreadWidgetConfigurationActivity"> diff --git a/app/k9mail/src/main/res/layout/message_list_widget_list_item.xml b/app/k9mail/src/main/res/layout/message_list_widget_list_item.xml index b1ab2c66480b8d1683c431b46b3d284a0bd34cf1..368ee21e97af4335a124510b6d07fd2338e60520 100644 --- a/app/k9mail/src/main/res/layout/message_list_widget_list_item.xml +++ b/app/k9mail/src/main/res/layout/message_list_widget_list_item.xml @@ -24,9 +24,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentEnd="true" - android:layout_alignParentRight="true" android:layout_marginStart="4dp" - android:layout_marginLeft="4dp" tools:text="25 May" /> @@ -48,11 +44,9 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentStart="true" - android:layout_alignParentLeft="true" android:layout_alignParentTop="true" android:layout_gravity="start" android:layout_toStartOf="@id/attachment" - android:layout_toLeftOf="@id/attachment" android:ellipsize="end" android:maxLines="1" android:textSize="16sp" @@ -64,7 +58,6 @@ android:layout_height="wrap_content" android:layout_below="@+id/sender" android:layout_alignParentStart="true" - android:layout_alignParentLeft="true" android:ellipsize="end" android:maxLines="1" android:paddingBottom="2dp" @@ -77,7 +70,6 @@ android:layout_height="wrap_content" android:layout_below="@+id/mail_subject" android:layout_alignParentStart="true" - android:layout_alignParentLeft="true" android:maxLines="1" android:textSize="13sp" tools:text="Towel Day is celebrated every year on 25 May as a tribute to the author Douglas Adams by his fans." /> diff --git a/app/k9mail/src/main/res/xml/network_security_config.xml b/app/k9mail/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000000000000000000000000000000000000..094d43225127492bdcb2444b0c828a628d332cb8 --- /dev/null +++ b/app/k9mail/src/main/res/xml/network_security_config.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + 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..14a763039c75cd0067379e4be481cc5b8c52178f 100644 --- a/app/k9mail/src/test/java/com/fsck/k9/DependencyInjectionTest.kt +++ b/app/k9mail/src/test/java/com/fsck/k9/DependencyInjectionTest.kt @@ -10,7 +10,7 @@ import com.fsck.k9.ui.folders.FolderNameFormatter import com.fsck.k9.ui.helper.SizeFormatter import org.junit.Test import org.junit.runner.RunWith -import org.koin.core.annotation.KoinInternal +import org.koin.core.annotation.KoinInternalApi import org.koin.core.logger.PrintLogger import org.koin.core.parameter.parametersOf import org.koin.java.KoinJavaComponent @@ -31,17 +31,17 @@ class DependencyInjectionTest : AutoCloseKoinTest() { } val autocryptTransferView = mock() - @KoinInternal + @KoinInternalApi @Test fun testDependencyTree() { KoinJavaComponent.getKoin().setupLogger(PrintLogger()) getKoin().checkModules { - create { parametersOf(lifecycleOwner) } + withParameter { lifecycleOwner } create { parametersOf(lifecycleOwner, autocryptTransferView) } - create { parametersOf(RuntimeEnvironment.application) } - create { parametersOf(RuntimeEnvironment.application) } - create { parametersOf(ChangeLogMode.CHANGE_LOG) } + withParameter { RuntimeEnvironment.application } + withParameter { RuntimeEnvironment.application } + withParameter { ChangeLogMode.CHANGE_LOG } } } } diff --git a/app/k9mail/src/test/java/com/fsck/k9/widget/unread/UnreadWidgetDataProviderTest.kt b/app/k9mail/src/test/java/com/fsck/k9/widget/unread/UnreadWidgetDataProviderTest.kt index d85224e262cbcce0d3ce48a6da09c8d5a3107cee..e701181fb1c0bf8143d118dda24a6fd60bc462e7 100644 --- a/app/k9mail/src/test/java/com/fsck/k9/widget/unread/UnreadWidgetDataProviderTest.kt +++ b/app/k9mail/src/test/java/com/fsck/k9/widget/unread/UnreadWidgetDataProviderTest.kt @@ -7,7 +7,6 @@ import com.fsck.k9.Preferences import com.fsck.k9.controller.MessagingController import com.fsck.k9.mailstore.Folder import com.fsck.k9.mailstore.FolderRepository -import com.fsck.k9.mailstore.FolderRepositoryManager import com.fsck.k9.mailstore.FolderType import com.fsck.k9.search.SearchAccount import com.fsck.k9.ui.folders.FolderNameFormatter @@ -27,11 +26,11 @@ class UnreadWidgetDataProviderTest : AppRobolectricTest() { val preferences = createPreferences() val messagingController = createMessagingController() val defaultFolderStrategy = createDefaultFolderStrategy() - val folderRepositoryManager = createFolderRepositoryManager() + val folderRepository = createFolderRepository() val folderNameFormatterFactory = createFolderNameFormatterFactory() val provider = UnreadWidgetDataProvider( context, preferences, messagingController, defaultFolderStrategy, - folderRepositoryManager, folderNameFormatterFactory + folderRepository, folderNameFormatterFactory ) @Test @@ -102,16 +101,9 @@ class UnreadWidgetDataProviderTest : AppRobolectricTest() { on { getDefaultFolder(account) } doReturn FOLDER_ID } - fun createFolderRepositoryManager(): FolderRepositoryManager { - val folderRepository = createFolderRepository() - return mock { - on { getFolderRepository(account) } doReturn folderRepository - } - } - fun createFolderRepository(): FolderRepository { return mock { - on { getFolder(FOLDER_ID) } doReturn FOLDER + on { getFolder(account, FOLDER_ID) } doReturn FOLDER } } diff --git a/app/storage/build.gradle b/app/storage/build.gradle index db0391a5a7b02a2edd96b819aa0e6eb44d8d4a1c..5a319dc5ba5efc8bfa03c5f5a71bcffd9527b7df 100644 --- a/app/storage/build.gradle +++ b/app/storage/build.gradle @@ -2,7 +2,7 @@ apply plugin: 'com.android.library' apply plugin: 'org.jetbrains.kotlin.android' dependencies { - api "org.koin:koin-core:${versions.koin}" + api "io.insert-koin:koin-core:${versions.koin}" implementation project(":app:core") implementation "androidx.core:core-ktx:${versions.androidxCore}" @@ -18,7 +18,7 @@ dependencies { testImplementation "com.google.truth:truth:${versions.truth}" testImplementation "org.mockito:mockito-core:${versions.mockito}" testImplementation "org.mockito.kotlin:mockito-kotlin:${versions.mockitoKotlin}" - testImplementation "org.koin:koin-test:${versions.koin}" + testImplementation "io.insert-koin:koin-test-junit4:${versions.koin}" testImplementation "commons-io:commons-io:${versions.commonsIo}" } diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo6.kt b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo6.kt index 0066dd9a4e1fc5c9130b67746fe2a640f6ac0795..cbab8404783f01e8df6d2b90592af5013695cd3a 100644 --- a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo6.kt +++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo6.kt @@ -10,23 +10,10 @@ class StorageMigrationTo6( private val migrationsHelper: StorageMigrationsHelper ) { fun performLegacyMigrations() { - rewriteKeyguardPrivacy() rewriteTheme() migrateOpenPgpGlobalToAccountSettings() } - private fun rewriteKeyguardPrivacy() { - val notificationHideSubject = migrationsHelper.readValue(db, "notificationHideSubject") - if (notificationHideSubject == null) { - val keyguardPrivacy = migrationsHelper.readValue(db, "keyguardPrivacy") - if (keyguardPrivacy?.toBoolean() == true) { - migrationsHelper.writeValue(db, "notificationHideSubject", "WHEN_LOCKED") - } else { - migrationsHelper.writeValue(db, "notificationHideSubject", "NEVER") - } - } - } - private fun rewriteTheme() { val theme = migrationsHelper.readValue(db, "theme")?.toInt() diff --git a/app/storage/src/main/java/com/fsck/k9/storage/KoinModule.kt b/app/storage/src/main/java/com/fsck/k9/storage/KoinModule.kt index 3c68f9123774747ca3a75b54483f7e1b7edea792..7aff5493f4485fd8966f500e69de76fda43a2e96 100644 --- a/app/storage/src/main/java/com/fsck/k9/storage/KoinModule.kt +++ b/app/storage/src/main/java/com/fsck/k9/storage/KoinModule.kt @@ -2,7 +2,9 @@ package com.fsck.k9.storage import com.fsck.k9.mailstore.MessageStoreFactory import com.fsck.k9.mailstore.SchemaDefinitionFactory +import com.fsck.k9.notification.NotificationStoreProvider import com.fsck.k9.storage.messages.K9MessageStoreFactory +import com.fsck.k9.storage.notifications.K9NotificationStoreProvider import org.koin.dsl.module val storageModule = module { @@ -10,4 +12,7 @@ val storageModule = module { single { K9MessageStoreFactory(localStoreProvider = get(), storageManager = get(), basicPartInfoExtractor = get()) } + single { + K9NotificationStoreProvider(localStoreProvider = get()) + } } diff --git a/app/storage/src/main/java/com/fsck/k9/storage/StoreSchemaDefinition.java b/app/storage/src/main/java/com/fsck/k9/storage/StoreSchemaDefinition.java index b25f13963f4c584ba075c1d01367a90605d690f8..0e75735da595674662600c99440055049ca3036e 100644 --- a/app/storage/src/main/java/com/fsck/k9/storage/StoreSchemaDefinition.java +++ b/app/storage/src/main/java/com/fsck/k9/storage/StoreSchemaDefinition.java @@ -12,7 +12,7 @@ import timber.log.Timber; class StoreSchemaDefinition implements SchemaDefinition { - static final int DB_VERSION = 79; + static final int DB_VERSION = 83; private final MigrationsHelper migrationsHelper; @@ -140,9 +140,20 @@ class StoreSchemaDefinition implements SchemaDefinition { "answered INTEGER default 0, " + "forwarded INTEGER default 0, " + "message_part_id INTEGER," + - "encryption_type TEXT" + + "encryption_type TEXT," + + "new_message INTEGER DEFAULT 0" + ")"); + db.execSQL("DROP INDEX IF EXISTS new_messages"); + db.execSQL("CREATE INDEX IF NOT EXISTS new_messages ON messages(new_message)"); + + db.execSQL("CREATE TRIGGER new_message_reset " + + "AFTER UPDATE OF read ON messages " + + "FOR EACH ROW WHEN NEW.read = 1 AND NEW.new_message = 1 " + + "BEGIN " + + "UPDATE messages SET new_message = 0 WHERE ROWID = NEW.ROWID; " + + "END"); + db.execSQL("DROP TABLE IF EXISTS message_parts"); db.execSQL("CREATE TABLE message_parts (" + "id INTEGER PRIMARY KEY, " + @@ -248,5 +259,15 @@ class StoreSchemaDefinition implements SchemaDefinition { db.execSQL("DROP TABLE IF EXISTS messages_fulltext"); db.execSQL("CREATE VIRTUAL TABLE messages_fulltext USING fts4 (fulltext)"); + + db.execSQL("DROP TABLE IF EXISTS notifications"); + db.execSQL("CREATE TABLE notifications (" + + "message_id INTEGER PRIMARY KEY NOT NULL REFERENCES messages(id) ON DELETE CASCADE," + + "notification_id INTEGER UNIQUE," + + "timestamp INTEGER NOT NULL" + + ")"); + + db.execSQL("DROP INDEX IF EXISTS notifications_timestamp"); + db.execSQL("CREATE INDEX IF NOT EXISTS notifications_timestamp ON notifications(timestamp)"); } } diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/CheckFolderOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/CheckFolderOperations.kt new file mode 100644 index 0000000000000000000000000000000000000000..c7975042d84af30509a99abfeaec2dbaa049519b --- /dev/null +++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/CheckFolderOperations.kt @@ -0,0 +1,33 @@ +package com.fsck.k9.storage.messages + +import com.fsck.k9.mailstore.LockableDatabase + +internal class CheckFolderOperations(private val lockableDatabase: LockableDatabase) { + fun areAllIncludedInUnifiedInbox(folderIds: Collection): Boolean { + return lockableDatabase.execute(false) { database -> + var allIncludedInUnifiedInbox = true + + performChunkedOperation( + arguments = folderIds, + argumentTransformation = Long::toString + ) { selectionSet, selectionArguments -> + if (allIncludedInUnifiedInbox) { + database.rawQuery( + "SELECT COUNT(id) FROM folders WHERE integrate = 1 AND id $selectionSet", selectionArguments + ).use { cursor -> + if (cursor.moveToFirst()) { + val count = cursor.getInt(0) + if (count != selectionArguments.size) { + allIncludedInUnifiedInbox = false + } + } else { + allIncludedInUnifiedInbox = false + } + } + } + } + + allIncludedInUnifiedInbox + } + } +} diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/DatabaseOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/DatabaseOperations.kt new file mode 100644 index 0000000000000000000000000000000000000000..1558190c6b4f66fd1a10e1047a29112ec3629632 --- /dev/null +++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/DatabaseOperations.kt @@ -0,0 +1,40 @@ +package com.fsck.k9.storage.messages + +import com.fsck.k9.mailstore.LockableDatabase +import com.fsck.k9.mailstore.StorageManager +import timber.log.Timber + +internal class DatabaseOperations( + private val lockableDatabase: LockableDatabase, + val storageManager: StorageManager, + val accountUuid: String +) { + fun getSize(): Long { + val storageProviderId = lockableDatabase.storageProviderId + val attachmentDirectory = storageManager.getAttachmentDirectory(accountUuid, storageProviderId) + + return lockableDatabase.execute(false) { + val attachmentFiles = attachmentDirectory.listFiles() ?: emptyArray() + val attachmentsSize = attachmentFiles.asSequence() + .filter { file -> file.exists() } + .fold(initial = 0L) { accumulatedSize, file -> + accumulatedSize + file.length() + } + + val databaseFile = storageManager.getDatabase(accountUuid, storageProviderId) + val databaseSize = databaseFile.length() + + databaseSize + attachmentsSize + } + } + + fun compact() { + Timber.i("Before compaction size = %d", getSize()) + + lockableDatabase.execute(false) { database -> + database.execSQL("VACUUM") + } + + Timber.i("After compaction size = %d", getSize()) + } +} 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 979f874e7f4489513d3dfd708987932d2affeeaa..2f617fa593b4755e20f91190ea03b19587cd32fb 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 @@ -33,13 +33,16 @@ class K9MessageStore( private val copyMessageOperations = CopyMessageOperations(database, attachmentFileManager, threadMessageOperations) private val moveMessageOperations = MoveMessageOperations(database, threadMessageOperations) private val flagMessageOperations = FlagMessageOperations(database) + private val updateMessageOperations = UpdateMessageOperations(database) private val retrieveMessageOperations = RetrieveMessageOperations(database) private val deleteMessageOperations = DeleteMessageOperations(database, attachmentFileManager) private val createFolderOperations = CreateFolderOperations(database) private val retrieveFolderOperations = RetrieveFolderOperations(database) + private val checkFolderOperations = CheckFolderOperations(database) private val updateFolderOperations = UpdateFolderOperations(database) private val deleteFolderOperations = DeleteFolderOperations(database, attachmentFileManager) private val keyValueStoreOperations = KeyValueStoreOperations(database) + private val databaseOperations = DatabaseOperations(database, storageManager, accountUuid) override fun saveRemoteMessage(folderId: Long, messageServerId: String, messageData: SaveMessageData) { saveMessageOperations.saveRemoteMessage(folderId, messageServerId, messageData) @@ -65,6 +68,14 @@ class K9MessageStore( flagMessageOperations.setMessageFlag(folderId, messageServerId, flag, set) } + override fun setNewMessageState(folderId: Long, messageServerId: String, newMessage: Boolean) { + updateMessageOperations.setNewMessageState(folderId, messageServerId, newMessage) + } + + override fun clearNewMessageState() { + updateMessageOperations.clearNewMessageState() + } + override fun getMessageServerId(messageId: Long): String { return retrieveMessageOperations.getMessageServerId(messageId) } @@ -97,10 +108,6 @@ class K9MessageStore( return retrieveMessageOperations.getHeaders(folderId, messageServerId) } - override fun getLastUid(folderId: Long): Long? { - return retrieveMessageOperations.getLastUid(folderId) - } - override fun destroyMessages(folderId: Long, messageServerIds: Collection) { deleteMessageOperations.destroyMessages(folderId, messageServerIds) } @@ -129,10 +136,26 @@ class K9MessageStore( return retrieveFolderOperations.getDisplayFolders(displayMode, outboxFolderId, mapper) } + override fun areAllIncludedInUnifiedInbox(folderIds: Collection): Boolean { + return checkFolderOperations.areAllIncludedInUnifiedInbox(folderIds) + } + override fun getFolderId(folderServerId: String): Long? { return retrieveFolderOperations.getFolderId(folderServerId) } + override fun getFolderServerId(folderId: Long): String? { + return retrieveFolderOperations.getFolderServerId(folderId) + } + + override fun getMessageCount(folderId: Long): Int { + return retrieveFolderOperations.getMessageCount(folderId) + } + + override fun getSize(): Long { + return databaseOperations.getSize() + } + override fun changeFolder(folderServerId: String, name: String, type: FolderType) { updateFolderOperations.changeFolder(folderServerId, name, type) } @@ -165,8 +188,8 @@ class K9MessageStore( updateFolderOperations.setMoreMessages(folderId, moreMessages) } - override fun setLastUpdated(folderId: Long, timestamp: Long) { - updateFolderOperations.setLastUpdated(folderId, timestamp) + override fun setLastChecked(folderId: Long, timestamp: Long) { + updateFolderOperations.setLastChecked(folderId, timestamp) } override fun setStatus(folderId: Long, status: String?) { @@ -208,4 +231,8 @@ class K9MessageStore( override fun setFolderExtraNumber(folderId: Long, name: String, value: Long) { return keyValueStoreOperations.setFolderExtraNumber(folderId, name, value) } + + override fun compact() { + return databaseOperations.compact() + } } 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 abe16112b4acf5dcc047d5c35615156a30e8415d..7a68605927b61632504a0560a1e363e2f16cc800 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 @@ -74,6 +75,12 @@ internal class RetrieveFolderOperations(private val lockableDatabase: LockableDa WHERE messages.folder_id = folders.id AND messages.empty = 0 AND messages.deleted = 0 AND (messages.read = 0 OR folders.id = ?) + ), ( + SELECT COUNT(messages.id) + FROM messages + WHERE messages.folder_id = folders.id + AND messages.empty = 0 AND messages.deleted = 0 + AND messages.flagged = 1 ) FROM folders $displayModeSelection @@ -123,6 +130,33 @@ 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( + "SELECT COUNT(id) FROM messages WHERE empty = 0 AND deleted = 0 AND folder_id = ?", + arrayOf(folderId.toString()) + ).use { cursor -> + if (cursor.moveToFirst()) cursor.getInt(0) else 0 + } + } + } } private class CursorFolderAccessor(val cursor: Cursor) : FolderDetailsAccessor { @@ -165,8 +199,14 @@ private class CursorFolderAccessor(val cursor: Cursor) : FolderDetailsAccessor { override val moreMessages: MoreMessages get() = MoreMessages.fromDatabaseName(cursor.getString(12)) - override val messageCount: Int - get() = cursor.getInt(13) + override val lastChecked: Long? + get() = cursor.getLongOrNull(13) + + override val unreadMessageCount: Int + get() = cursor.getInt(14) + + override val starredMessageCount: Int + get() = cursor.getInt(15) override fun serverIdOrThrow(): String { return serverId ?: error("No server ID found for folder '$name' ($id)") @@ -190,5 +230,6 @@ private val FOLDER_COLUMNS = arrayOf( "notify_class", "push_class", "visible_limit", - "more_messages" + "more_messages", + "last_updated" ) diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageOperations.kt index 078d19ec619dcdd519ed8f54244afa4d8b4a844b..a57bfee54110ac3ce3dd6a422f0e05abed6102f7 100644 --- a/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageOperations.kt +++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/RetrieveMessageOperations.kt @@ -189,19 +189,4 @@ internal class RetrieveMessageOperations(private val lockableDatabase: LockableD } } } - - fun getLastUid(folderId: Long): Long? { - return lockableDatabase.execute(false) { database -> - database.rawQuery( - "SELECT MAX(uid) FROM messages WHERE folder_id = ?", - arrayOf(folderId.toString()) - ).use { cursor -> - if (cursor.moveToFirst()) { - cursor.getLongOrNull(0) - } else { - null - } - } - } - } } 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 701e6f332941bedb34aeee83b06839cf5d414476..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 @@ -355,7 +355,7 @@ internal class SaveMessageOperations( private fun getTransferEncoding(part: Part): String { val contentTransferEncoding = part.getHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING).firstOrNull() - return contentTransferEncoding?.toLowerCase(Locale.ROOT) ?: MimeUtil.ENC_7BIT + return contentTransferEncoding?.lowercase() ?: MimeUtil.ENC_7BIT } private fun addChildrenToStack(stack: Stack, part: Part, parentId: Long) { 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 324841efdbfe6151351d723c6862f9f1db322184..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 { @@ -37,7 +36,7 @@ internal class ThreadMessageOperations { var referencesHeader: String? = null if (headerBytes != null) { MessageHeaderParser.parse(headerBytes.inputStream()) { name, value -> - when (name.toLowerCase(Locale.ROOT)) { + when (name.lowercase()) { "in-reply-to" -> inReplyToHeader = value "references" -> referencesHeader = value } diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/UpdateFolderOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/UpdateFolderOperations.kt index cfa1921e3baae1e16079e377ac294cdf877e69ba..06486aa2afe0aa7ab35f406d90c80d99607cd1b2 100644 --- a/app/storage/src/main/java/com/fsck/k9/storage/messages/UpdateFolderOperations.kt +++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/UpdateFolderOperations.kt @@ -65,7 +65,7 @@ internal class UpdateFolderOperations(private val lockableDatabase: LockableData setString(folderId = folderId, columnName = "more_messages", value = moreMessages.databaseName) } - fun setLastUpdated(folderId: Long, timestamp: Long) { + fun setLastChecked(folderId: Long, timestamp: Long) { lockableDatabase.execute(false) { db -> val contentValues = ContentValues().apply { put("last_updated", timestamp) diff --git a/app/storage/src/main/java/com/fsck/k9/storage/messages/UpdateMessageOperations.kt b/app/storage/src/main/java/com/fsck/k9/storage/messages/UpdateMessageOperations.kt new file mode 100644 index 0000000000000000000000000000000000000000..9c5891ee0698e6e899e81491fa313cc9dfac9320 --- /dev/null +++ b/app/storage/src/main/java/com/fsck/k9/storage/messages/UpdateMessageOperations.kt @@ -0,0 +1,28 @@ +package com.fsck.k9.storage.messages + +import android.content.ContentValues +import com.fsck.k9.mailstore.LockableDatabase + +internal class UpdateMessageOperations(private val lockableDatabase: LockableDatabase) { + + fun setNewMessageState(folderId: Long, messageServerId: String, newMessage: Boolean) { + lockableDatabase.execute(false) { database -> + val values = ContentValues().apply { + put("new_message", if (newMessage) 1 else 0) + } + + database.update( + "messages", + values, + "folder_id = ? AND uid = ?", + arrayOf(folderId.toString(), messageServerId) + ) + } + } + + fun clearNewMessageState() { + lockableDatabase.execute(false) { database -> + database.execSQL("UPDATE messages SET new_message = 0") + } + } +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..c435d9ead49f9714e232022db17aa6b825fbf0f0 --- /dev/null +++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo80.kt @@ -0,0 +1,12 @@ +package com.fsck.k9.storage.migrations + +import android.database.sqlite.SQLiteDatabase + +/** + * Rewrite 'last_update' column to NULL when the value is 0 + */ +internal class MigrationTo80(private val db: SQLiteDatabase) { + fun rewriteLastUpdatedColumn() { + db.execSQL("UPDATE folders SET last_updated = NULL WHERE last_updated = 0") + } +} diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo81.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo81.kt new file mode 100644 index 0000000000000000000000000000000000000000..60e67c7cf38f3a3517c5706bcacebf9bdff81c23 --- /dev/null +++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo81.kt @@ -0,0 +1,22 @@ +package com.fsck.k9.storage.migrations + +import android.database.sqlite.SQLiteDatabase + +/** + * Add 'notifications' table to keep track of notifications. + */ +internal class MigrationTo81(private val db: SQLiteDatabase) { + fun addNotificationsTable() { + db.execSQL("DROP TABLE IF EXISTS notifications") + db.execSQL( + "CREATE TABLE notifications (" + + "message_id INTEGER PRIMARY KEY NOT NULL REFERENCES messages(id) ON DELETE CASCADE," + + "notification_id INTEGER UNIQUE," + + "timestamp INTEGER NOT NULL" + + ")" + ) + + db.execSQL("DROP INDEX IF EXISTS notifications_timestamp") + db.execSQL("CREATE INDEX IF NOT EXISTS notifications_timestamp ON notifications(timestamp)") + } +} diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo82.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo82.kt new file mode 100644 index 0000000000000000000000000000000000000000..8d84acd03c1231cb2532eb1ac0f1f83769349f9a --- /dev/null +++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo82.kt @@ -0,0 +1,27 @@ +package com.fsck.k9.storage.migrations + +import android.database.sqlite.SQLiteDatabase + +/** + * Add 'new_message' column to 'messages' table. + */ +internal class MigrationTo82(private val db: SQLiteDatabase) { + fun addNewMessageColumn() { + db.execSQL("ALTER TABLE messages ADD new_message INTEGER DEFAULT 0") + + db.execSQL("DROP INDEX IF EXISTS new_messages") + db.execSQL("CREATE INDEX IF NOT EXISTS new_messages ON messages(new_message)") + + db.execSQL( + "CREATE TRIGGER new_message_reset " + + "AFTER UPDATE OF read ON messages " + + "FOR EACH ROW WHEN NEW.read = 1 AND NEW.new_message = 1 " + + "BEGIN " + + "UPDATE messages SET new_message = 0 WHERE ROWID = NEW.ROWID; " + + "END" + ) + + // Mark messages with existing notifications as "new" + db.execSQL("UPDATE messages SET new_message = 1 WHERE id in (SELECT message_id FROM notifications)") + } +} diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo83.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo83.kt new file mode 100644 index 0000000000000000000000000000000000000000..47952939d1462d5a676b87952d0a1d9f9d374ca9 --- /dev/null +++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/MigrationTo83.kt @@ -0,0 +1,42 @@ +package com.fsck.k9.storage.migrations + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import androidx.core.database.getLongOrNull +import com.fsck.k9.helper.map +import com.fsck.k9.mailstore.MigrationsHelper + +private const val EXTRA_HIGHEST_KNOWN_UID = "imapHighestKnownUid" + +/** + * Write the highest known IMAP message UID to the 'folder_extra_values' table. + */ +internal class MigrationTo83(private val db: SQLiteDatabase, private val migrationsHelper: MigrationsHelper) { + fun rewriteHighestKnownUid() { + if (migrationsHelper.account.incomingServerSettings.type != "imap") return + + val highestKnownUids = db.rawQuery( + "SELECT folder_id, MAX(CAST(uid AS INTEGER)) FROM messages GROUP BY folder_id", + null + ).use { cursor -> + cursor.map { + it.getLong(0) to it.getLongOrNull(1) + }.toMap() + } + + for ((folderId, highestKnownUid) in highestKnownUids) { + if (highestKnownUid != null && highestKnownUid > 0L) { + rewriteHighestKnownUid(folderId, highestKnownUid) + } + } + } + + private fun rewriteHighestKnownUid(folderId: Long, highestKnownUid: Long) { + val contentValues = ContentValues().apply { + put("folder_id", folderId) + put("name", EXTRA_HIGHEST_KNOWN_UID) + put("value_integer", highestKnownUid) + } + db.insertWithOnConflict("folder_extra_values", null, contentValues, SQLiteDatabase.CONFLICT_REPLACE) + } +} diff --git a/app/storage/src/main/java/com/fsck/k9/storage/migrations/Migrations.kt b/app/storage/src/main/java/com/fsck/k9/storage/migrations/Migrations.kt index 4cb79662eaf0ae7f127f3f605b8b79e3f3e2b20a..51f373d30b481427c04ede63381e6e16ed6fd3ce 100644 --- a/app/storage/src/main/java/com/fsck/k9/storage/migrations/Migrations.kt +++ b/app/storage/src/main/java/com/fsck/k9/storage/migrations/Migrations.kt @@ -25,5 +25,9 @@ object Migrations { // 77: No longer necessary if (oldVersion < 78) MigrationTo78(db).removeServerIdFromLocalFolders() if (oldVersion < 79) MigrationTo79(db).updateDeleteMessageTrigger() + if (oldVersion < 80) MigrationTo80(db).rewriteLastUpdatedColumn() + if (oldVersion < 81) MigrationTo81(db).addNotificationsTable() + if (oldVersion < 82) MigrationTo82(db).addNewMessageColumn() + if (oldVersion < 83) MigrationTo83(db, migrationsHelper).rewriteHighestKnownUid() } } diff --git a/app/storage/src/main/java/com/fsck/k9/storage/notifications/K9NotificationStore.kt b/app/storage/src/main/java/com/fsck/k9/storage/notifications/K9NotificationStore.kt new file mode 100644 index 0000000000000000000000000000000000000000..4f3527efa7ec98d05e2b4e4500c83ad05f0427dd --- /dev/null +++ b/app/storage/src/main/java/com/fsck/k9/storage/notifications/K9NotificationStore.kt @@ -0,0 +1,72 @@ +package com.fsck.k9.storage.notifications + +import android.database.sqlite.SQLiteDatabase +import com.fsck.k9.controller.MessageReference +import com.fsck.k9.mailstore.LockableDatabase +import com.fsck.k9.notification.NotificationStore +import com.fsck.k9.notification.NotificationStoreOperation + +class K9NotificationStore(private val lockableDatabase: LockableDatabase) : NotificationStore { + override fun persistNotificationChanges(operations: List) { + lockableDatabase.execute(true) { db -> + for (operation in operations) { + when (operation) { + is NotificationStoreOperation.Add -> { + addNotification(db, operation.messageReference, operation.notificationId, operation.timestamp) + } + is NotificationStoreOperation.ChangeToActive -> { + setNotificationId(db, operation.messageReference, operation.notificationId) + } + is NotificationStoreOperation.ChangeToInactive -> { + clearNotificationId(db, operation.messageReference) + } + is NotificationStoreOperation.Remove -> { + removeNotification(db, operation.messageReference) + } + } + } + } + } + + override fun clearNotifications() { + lockableDatabase.execute(false) { db -> + db.delete("notifications", null, null) + } + } + + private fun addNotification( + database: SQLiteDatabase, + messageReference: MessageReference, + notificationId: Int, + timestamp: Long + ) { + database.execSQL( + "INSERT INTO notifications(message_id, notification_id, timestamp) " + + "SELECT id, ?, ? FROM messages WHERE folder_id = ? AND uid = ?", + arrayOf(notificationId, timestamp, messageReference.folderId, messageReference.uid) + ) + } + + private fun setNotificationId(database: SQLiteDatabase, messageReference: MessageReference, notificationId: Int) { + database.execSQL( + "UPDATE notifications SET notification_id = ? WHERE message_id IN " + + "(SELECT id FROM messages WHERE folder_id = ? AND uid = ?)", + arrayOf(notificationId, messageReference.folderId, messageReference.uid) + ) + } + + private fun clearNotificationId(database: SQLiteDatabase, messageReference: MessageReference) { + database.execSQL( + "UPDATE notifications SET notification_id = NULL WHERE message_id IN " + + "(SELECT id FROM messages WHERE folder_id = ? AND uid = ?)", + arrayOf(messageReference.folderId, messageReference.uid) + ) + } + + private fun removeNotification(database: SQLiteDatabase, messageReference: MessageReference) { + database.execSQL( + "DELETE FROM notifications WHERE message_id IN (SELECT id FROM messages WHERE folder_id = ? AND uid = ?)", + arrayOf(messageReference.folderId, messageReference.uid) + ) + } +} diff --git a/app/storage/src/main/java/com/fsck/k9/storage/notifications/K9NotificationStoreProvider.kt b/app/storage/src/main/java/com/fsck/k9/storage/notifications/K9NotificationStoreProvider.kt new file mode 100644 index 0000000000000000000000000000000000000000..09a642f8f8357091e3464f79ebe9823071ab0933 --- /dev/null +++ b/app/storage/src/main/java/com/fsck/k9/storage/notifications/K9NotificationStoreProvider.kt @@ -0,0 +1,13 @@ +package com.fsck.k9.storage.notifications + +import com.fsck.k9.Account +import com.fsck.k9.mailstore.LocalStoreProvider +import com.fsck.k9.notification.NotificationStore +import com.fsck.k9.notification.NotificationStoreProvider + +class K9NotificationStoreProvider(private val localStoreProvider: LocalStoreProvider) : NotificationStoreProvider { + override fun getNotificationStore(account: Account): NotificationStore { + val localStore = localStoreProvider.getInstance(account) + return K9NotificationStore(lockableDatabase = localStore.database) + } +} 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/TestApp.kt b/app/storage/src/test/java/com/fsck/k9/storage/TestApp.kt index 89d61001963f12cc84084e2c819f826d16aa79b8..97dbdb0ff2c940528d4cb2366189aae6938bcd36 100644 --- a/app/storage/src/test/java/com/fsck/k9/storage/TestApp.kt +++ b/app/storage/src/test/java/com/fsck/k9/storage/TestApp.kt @@ -16,7 +16,7 @@ import org.mockito.kotlin.mock class TestApp : Application() { override fun onCreate() { - Core.earlyInit(this) + Core.earlyInit() super.onCreate() DI.start(this, coreModules + storageModule + testModule) diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/CheckFolderOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/CheckFolderOperationsTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..5a16c6b8ebbdc30b120232ca54d1361f9f398051 --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/CheckFolderOperationsTest.kt @@ -0,0 +1,63 @@ +package com.fsck.k9.storage.messages + +import com.fsck.k9.storage.RobolectricTest +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class CheckFolderOperationsTest : RobolectricTest() { + private val sqliteDatabase = createDatabase() + private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase) + private val checkFolderOperations = CheckFolderOperations(lockableDatabase) + + @Test + fun `single folder not included in Unified Inbox`() { + val folderIds = listOf(sqliteDatabase.createFolder(integrate = false)) + + val result = checkFolderOperations.areAllIncludedInUnifiedInbox(folderIds) + + assertThat(result).isFalse() + } + + @Test + fun `single folder included in Unified Inbox`() { + val folderIds = listOf(sqliteDatabase.createFolder(integrate = true)) + + val result = checkFolderOperations.areAllIncludedInUnifiedInbox(folderIds) + + assertThat(result).isTrue() + } + + @Test + fun `not all folders included in Unified Inbox`() { + val folderIds = listOf( + sqliteDatabase.createFolder(integrate = true), + sqliteDatabase.createFolder(integrate = false) + ) + + val result = checkFolderOperations.areAllIncludedInUnifiedInbox(folderIds) + + assertThat(result).isFalse() + } + + @Test + fun `1000 folders included in Unified Inbox`() { + val folderIds = List(1000) { + sqliteDatabase.createFolder(integrate = true) + } + + val result = checkFolderOperations.areAllIncludedInUnifiedInbox(folderIds) + + assertThat(result).isTrue() + } + + @Test + fun `999 of 1000 folders included in Unified Inbox`() { + val folderIds = List(999) { + sqliteDatabase.createFolder(integrate = true) + } + sqliteDatabase.createFolder(integrate = false) + + val result = checkFolderOperations.areAllIncludedInUnifiedInbox(folderIds) + + assertThat(result).isFalse() + } +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/MessageDatabaseHelpers.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/MessageDatabaseHelpers.kt index 3afdeacf26275943844df5cbc733f1a51896eecd..db1e3aebcb7932695e4711998602adf269957579 100644 --- a/app/storage/src/test/java/com/fsck/k9/storage/messages/MessageDatabaseHelpers.kt +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/MessageDatabaseHelpers.kt @@ -62,7 +62,8 @@ fun SQLiteDatabase.createMessage( answered: Boolean = false, forwarded: Boolean = false, messagePartId: Long = 0L, - encryptionType: String? = null + encryptionType: String? = null, + newMessage: Boolean = false ): Long { val values = ContentValues().apply { put("deleted", if (deleted) 1 else 0) @@ -90,6 +91,7 @@ fun SQLiteDatabase.createMessage( put("forwarded", if (forwarded) 1 else 0) put("message_part_id", messagePartId) put("encryption_type", encryptionType) + put("new_message", if (newMessage) 1 else 0) } return insert("messages", null, values) @@ -125,7 +127,8 @@ fun SQLiteDatabase.readMessages(): List { answered = cursor.getIntOrNull("answered"), forwarded = cursor.getIntOrNull("forwarded"), messagePartId = cursor.getLongOrNull("message_part_id"), - encryptionType = cursor.getStringOrNull("encryption_type") + encryptionType = cursor.getStringOrNull("encryption_type"), + newMessage = cursor.getIntOrNull("new_message") ) } } @@ -157,7 +160,8 @@ data class MessageEntry( val answered: Int?, val forwarded: Int?, val messagePartId: Long?, - val encryptionType: String? + val encryptionType: String?, + val newMessage: Int? ) fun SQLiteDatabase.createMessagePart( 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 4ab153ec24579f25c5f567a8495f10b60591a4a3..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 @@ -269,7 +269,7 @@ class RetrieveFolderOperationsTest : RobolectricTest() { displayMode = FolderMode.ALL, outboxFolderId = folderId2 ) { folder -> - Triple(folder.id, folder.name, folder.messageCount) + Triple(folder.id, folder.name, folder.unreadMessageCount) } assertThat(result).hasSize(4) @@ -301,4 +301,50 @@ 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() + + val result = retrieveFolderOperations.getMessageCount(folderId) + + assertThat(result).isEqualTo(0) + } + + @Test + fun `get message count from non-existent folder`() { + val result = retrieveFolderOperations.getMessageCount(23) + + assertThat(result).isEqualTo(0) + } + + @Test + fun `get message count from non-empty folder`() { + val folderId = sqliteDatabase.createFolder() + sqliteDatabase.createMessage(folderId = folderId) + sqliteDatabase.createMessage(folderId = folderId) + + val result = retrieveFolderOperations.getMessageCount(folderId) + + assertThat(result).isEqualTo(2) + } } diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageOperationsTest.kt index 8e9c91ba5c469de47f35849997363fa7c17336d3..5825dccec9db352ef7e80365ca722d77b9bc5f0b 100644 --- a/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageOperationsTest.kt +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/RetrieveMessageOperationsTest.kt @@ -169,27 +169,6 @@ class RetrieveMessageOperationsTest : RobolectricTest() { ) } - @Test - fun `get highest message uid`() { - val folderId = sqliteDatabase.createFolder() - sqliteDatabase.createMessage(uid = "42", folderId = folderId) - sqliteDatabase.createMessage(uid = "23", folderId = folderId) - sqliteDatabase.createMessage(uid = "27", folderId = folderId) - - val highestUid = retrieveMessageOperations.getLastUid(folderId) - - assertThat(highestUid).isEqualTo(42) - } - - @Test - fun `get highest message uid should return null if there are no messages`() { - val folderId = sqliteDatabase.createFolder() - - val highestUid = retrieveMessageOperations.getLastUid(folderId) - - assertThat(highestUid).isNull() - } - @Test fun `get oldest message date`() { sqliteDatabase.createMessage(folderId = 1, date = 42) diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateFolderOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateFolderOperationsTest.kt index 6bf2f2bd85d0366b5113d9e74b3d004e298864f7..83bc4fe5f9e23ef90b77bcff32086b839ffa1771 100644 --- a/app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateFolderOperationsTest.kt +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateFolderOperationsTest.kt @@ -135,7 +135,7 @@ class UpdateFolderOperationsTest : RobolectricTest() { fun `update late updated state`() { val folderId = sqliteDatabase.createFolder(lastUpdated = 23) - updateFolderOperations.setLastUpdated(folderId = folderId, timestamp = 42) + updateFolderOperations.setLastChecked(folderId = folderId, timestamp = 42) val folder = sqliteDatabase.readFolders().first() assertThat(folder.id).isEqualTo(folderId) diff --git a/app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateMessageOperationsTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateMessageOperationsTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..15a03d03b1321c93f19c5775ea927c36dcbbb72f --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/messages/UpdateMessageOperationsTest.kt @@ -0,0 +1,52 @@ +package com.fsck.k9.storage.messages + +import com.fsck.k9.storage.RobolectricTest +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class UpdateMessageOperationsTest : RobolectricTest() { + private val sqliteDatabase = createDatabase() + private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase) + private val updateMessageOperations = UpdateMessageOperations(lockableDatabase) + + @Test + fun `mark message as new`() { + sqliteDatabase.createMessage(folderId = 1, uid = "uid1", newMessage = false) + + updateMessageOperations.setNewMessageState(folderId = 1, messageServerId = "uid1", newMessage = true) + + val messages = sqliteDatabase.readMessages() + assertThat(messages).hasSize(1) + + val message = messages.first() + assertThat(message.newMessage).isEqualTo(1) + } + + @Test + fun `mark message as not new`() { + sqliteDatabase.createMessage(folderId = 1, uid = "uid1", newMessage = true) + + updateMessageOperations.setNewMessageState(folderId = 1, messageServerId = "uid1", newMessage = false) + + val messages = sqliteDatabase.readMessages() + assertThat(messages).hasSize(1) + + val message = messages.first() + assertThat(message.newMessage).isEqualTo(0) + } + + @Test + fun `clear new message state`() { + sqliteDatabase.createMessage(folderId = 1, uid = "uid1", newMessage = true) + sqliteDatabase.createMessage(folderId = 1, uid = "uid1", newMessage = false) + + updateMessageOperations.clearNewMessageState() + + val messages = sqliteDatabase.readMessages() + assertThat(messages).hasSize(2) + + for (message in messages) { + assertThat(message.newMessage).isEqualTo(0) + } + } +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/notifications/K9NotificationStoreTest.kt b/app/storage/src/test/java/com/fsck/k9/storage/notifications/K9NotificationStoreTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..aee0e76152c04ec3556ba66c5575c608c2ad317c --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/notifications/K9NotificationStoreTest.kt @@ -0,0 +1,103 @@ +package com.fsck.k9.storage.notifications + +import com.fsck.k9.controller.MessageReference +import com.fsck.k9.notification.NotificationStoreOperation +import com.fsck.k9.storage.RobolectricTest +import com.fsck.k9.storage.messages.createDatabase +import com.fsck.k9.storage.messages.createLockableDatabaseMock +import com.fsck.k9.storage.messages.createMessage +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +private const val FOLDER_ID = 1L + +class K9NotificationStoreTest : RobolectricTest() { + private val sqliteDatabase = createDatabase() + private val lockableDatabase = createLockableDatabaseMock(sqliteDatabase) + private val store = K9NotificationStore(lockableDatabase) + private val messageIdOne = sqliteDatabase.createMessage(folderId = FOLDER_ID, uid = "uid-1") + private val messageIdTwo = sqliteDatabase.createMessage(folderId = FOLDER_ID, uid = "uid-2") + + @Test + fun `add notification`() { + val operations = listOf( + NotificationStoreOperation.Add( + messageReference = createMessageReference("uid-1"), + notificationId = 42, + timestamp = 23L + ) + ) + + store.persistNotificationChanges(operations) + + val notifications = sqliteDatabase.readNotifications() + assertThat(notifications).hasSize(1) + val notification = notifications.first() + assertThat(notification.messageId).isEqualTo(messageIdOne) + assertThat(notification.notificationId).isEqualTo(42) + assertThat(notification.timestamp).isEqualTo(23L) + } + + @Test + fun `replace notification when adding`() { + sqliteDatabase.createNotification(messageId = messageIdOne, notificationId = 10, timestamp = 20L) + val operations = listOf( + NotificationStoreOperation.ChangeToInactive(messageReference = createMessageReference("uid-1")), + NotificationStoreOperation.Add( + messageReference = createMessageReference("uid-2"), + notificationId = 10, + timestamp = 30L + ) + ) + + store.persistNotificationChanges(operations) + + val notifications = sqliteDatabase.readNotifications() + assertThat(notifications).hasSize(2) + val originalNotification = notifications.first { it.messageId == messageIdOne } + assertThat(originalNotification.notificationId).isNull() + assertThat(originalNotification.timestamp).isEqualTo(20L) + val newNotification = notifications.first { it.messageId == messageIdTwo } + assertThat(newNotification.notificationId).isEqualTo(10) + assertThat(newNotification.timestamp).isEqualTo(30L) + } + + @Test + fun `remove notification`() { + sqliteDatabase.createNotification(messageId = messageIdOne, notificationId = 10, timestamp = 20L) + val operations = listOf( + NotificationStoreOperation.Remove(messageReference = createMessageReference("uid-1")) + ) + + store.persistNotificationChanges(operations) + + val notifications = sqliteDatabase.readNotifications() + assertThat(notifications).isEmpty() + } + + @Test + fun `replace notification when removing`() { + sqliteDatabase.createNotification(messageId = messageIdOne, notificationId = null, timestamp = 20L) + sqliteDatabase.createNotification(messageId = messageIdTwo, notificationId = 23, timestamp = 21L) + val operations = listOf( + NotificationStoreOperation.Remove(messageReference = createMessageReference("uid-2")), + NotificationStoreOperation.ChangeToActive( + messageReference = createMessageReference("uid-1"), + notificationId = 23 + ) + ) + + store.persistNotificationChanges(operations) + + val notifications = sqliteDatabase.readNotifications() + assertThat(notifications).hasSize(1) + val notification = notifications.first() + assertThat(notification.messageId).isEqualTo(messageIdOne) + assertThat(notification.notificationId).isEqualTo(23) + assertThat(notification.timestamp).isEqualTo(20L) + } + + private fun createMessageReference(uid: String): MessageReference { + return MessageReference(accountUuid = "00000000-0000-4000-0000-000000000000", FOLDER_ID, uid) + } +} diff --git a/app/storage/src/test/java/com/fsck/k9/storage/notifications/NotificationsTableHelpers.kt b/app/storage/src/test/java/com/fsck/k9/storage/notifications/NotificationsTableHelpers.kt new file mode 100644 index 0000000000000000000000000000000000000000..80f0461e4d6a1d9b044b960f48707f5c58c94fee --- /dev/null +++ b/app/storage/src/test/java/com/fsck/k9/storage/notifications/NotificationsTableHelpers.kt @@ -0,0 +1,40 @@ +package com.fsck.k9.storage.notifications + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import com.fsck.k9.helper.getIntOrNull +import com.fsck.k9.helper.getLongOrNull +import com.fsck.k9.helper.map + +fun SQLiteDatabase.createNotification( + messageId: Long, + notificationId: Int? = null, + timestamp: Long = 0L +): Long { + val values = ContentValues().apply { + put("message_id", messageId) + put("notification_id", notificationId) + put("timestamp", timestamp) + } + + return insert("notifications", null, values) +} + +fun SQLiteDatabase.readNotifications(): List { + val cursor = rawQuery("SELECT * FROM notifications", null) + return cursor.use { + cursor.map { + NotificationEntry( + messageId = cursor.getLongOrNull("message_id"), + notificationId = cursor.getIntOrNull("notification_id"), + timestamp = cursor.getLongOrNull("timestamp") + ) + } + } +} + +data class NotificationEntry( + val messageId: Long?, + val notificationId: Int?, + val timestamp: Long?, +) diff --git a/app/testing/build.gradle b/app/testing/build.gradle index dcbf59e724ca276e488a21e8859f62475fc587dc..91d2eb8a26cbdda20d8ae4c775932f4d99831b6c 100644 --- a/app/testing/build.gradle +++ b/app/testing/build.gradle @@ -6,7 +6,7 @@ dependencies { api "junit:junit:${versions.junit}" api "org.robolectric:robolectric:${versions.robolectric}" - api "org.koin:koin-core:${versions.koin}" + api "io.insert-koin:koin-core:${versions.koin}" api "org.mockito:mockito-core:${versions.mockito}" api "org.mockito.kotlin:mockito-kotlin:${versions.mockitoKotlin}" } 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.java b/app/testing/src/main/java/com/fsck/k9/testing/MockHelper.java deleted file mode 100644 index 7a693311d7f55d00968a459455eadfbb678c6fa7..0000000000000000000000000000000000000000 --- a/app/testing/src/main/java/com/fsck/k9/testing/MockHelper.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.fsck.k9.testing; - - -import org.mockito.invocation.InvocationOnMock; -import org.mockito.stubbing.Answer; - -import static org.mockito.Mockito.RETURNS_DEFAULTS; -import static org.mockito.Mockito.mock; - - -public class MockHelper { - public static T mockBuilder(Class classToMock) { - return mock(classToMock, new Answer() { - @Override - public Object answer(InvocationOnMock invocation) throws Throwable { - Object mock = invocation.getMock(); - if (invocation.getMethod().getReturnType().isInstance(mock)) { - return mock; - } else { - return RETURNS_DEFAULTS.answer(invocation); - } - } - }); - } -} 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 new file mode 100644 index 0000000000000000000000000000000000000000..17e9e6986e5e593d195b2ed95a3aa6377e9b1e8b --- /dev/null +++ b/app/testing/src/main/java/com/fsck/k9/testing/MockHelper.kt @@ -0,0 +1,23 @@ +package com.fsck.k9.testing + +import org.mockito.Mockito +import org.mockito.Mockito.mock +import org.mockito.kotlin.KStubbing + +object MockHelper { + @JvmStatic + fun mockBuilder(classToMock: Class): T { + return mock(classToMock) { invocation -> + val mock = invocation.mock + if (invocation.method.returnType.isInstance(mock)) { + mock + } else { + Mockito.RETURNS_DEFAULTS.answer(invocation) + } + } + } + + inline fun mockBuilder(stubbing: KStubbing.(T) -> Unit = {}): T { + return mockBuilder(T::class.java).apply { KStubbing(this).stubbing(this) } + } +} diff --git a/app/ui/base/build.gradle b/app/ui/base/build.gradle index 63d9fac9993cc04c89eb43915efb2ad921da1975..6b94ff2b65c822963c0c1a44d42e06b049ef0c0a 100644 --- a/app/ui/base/build.gradle +++ b/app/ui/base/build.gradle @@ -5,11 +5,14 @@ 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}" api "androidx.lifecycle:lifecycle-livedata-ktx:${versions.androidxLifecycle}" implementation "androidx.core:core-ktx:${versions.androidxCore}" + implementation "androidx.biometric:biometric:${versions.androidxBiometric}" implementation "com.jakewharton.timber:timber:${versions.timber}" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:${versions.kotlinCoroutines}" } diff --git a/app/ui/base/src/main/java/com/fsck/k9/ui/base/KoinModule.kt b/app/ui/base/src/main/java/com/fsck/k9/ui/base/KoinModule.kt index 3b845908fde74d45fa9b27316eac2adc3c0d6fcf..a23592e87bb4b1e8800aa271a6ddbab7115f8eab 100644 --- a/app/ui/base/src/main/java/com/fsck/k9/ui/base/KoinModule.kt +++ b/app/ui/base/src/main/java/com/fsck/k9/ui/base/KoinModule.kt @@ -1,8 +1,9 @@ package com.fsck.k9.ui.base +import org.koin.core.qualifier.named import org.koin.dsl.module val uiBaseModule = module { - single { ThemeManager(context = get(), themeProvider = get()) } + single { ThemeManager(context = get(), themeProvider = get(), generalSettingsManager = get(), appCoroutineScope = get(named("AppCoroutineScope"))) } single { AppLanguageManager() } } diff --git a/app/ui/base/src/main/java/com/fsck/k9/ui/base/ThemeManager.kt b/app/ui/base/src/main/java/com/fsck/k9/ui/base/ThemeManager.kt index cdf77931bdc89d212dfe743b8f625290cf509196..104c7dbb2f8edb6dfdf2d483c5ac9607634ae56f 100644 --- a/app/ui/base/src/main/java/com/fsck/k9/ui/base/ThemeManager.kt +++ b/app/ui/base/src/main/java/com/fsck/k9/ui/base/ThemeManager.kt @@ -5,37 +5,51 @@ import android.content.res.Configuration import android.os.Build import androidx.annotation.StyleRes import androidx.appcompat.app.AppCompatDelegate -import com.fsck.k9.K9 -import com.fsck.k9.K9.AppTheme -import com.fsck.k9.K9.SubTheme +import com.fsck.k9.preferences.AppTheme +import com.fsck.k9.preferences.GeneralSettings +import com.fsck.k9.preferences.GeneralSettingsManager +import com.fsck.k9.preferences.SubTheme +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.plus class ThemeManager( private val context: Context, - private val themeProvider: ThemeProvider + private val themeProvider: ThemeProvider, + private val generalSettingsManager: GeneralSettingsManager, + private val appCoroutineScope: CoroutineScope, ) { + + private val generalSettings: GeneralSettings + get() = generalSettingsManager.getSettings() + val appTheme: Theme - get() = when (K9.appTheme) { + get() = when (generalSettings.appTheme) { AppTheme.LIGHT -> Theme.LIGHT AppTheme.DARK -> Theme.DARK AppTheme.FOLLOW_SYSTEM -> if (Build.VERSION.SDK_INT < 28) Theme.LIGHT else getSystemTheme() } val messageViewTheme: Theme - get() = resolveTheme(K9.messageViewTheme) + get() = resolveTheme(generalSettings.messageViewTheme) val messageComposeTheme: Theme - get() = resolveTheme(K9.messageComposeTheme) + get() = resolveTheme(generalSettings.messageComposeTheme) @get:StyleRes val appThemeResourceId: Int = themeProvider.appThemeResourceId @get:StyleRes val messageViewThemeResourceId: Int - get() = getSubThemeResourceId(K9.messageViewTheme) + get() = getSubThemeResourceId(generalSettings.messageViewTheme) @get:StyleRes val messageComposeThemeResourceId: Int - get() = getSubThemeResourceId(K9.messageComposeTheme) + get() = getSubThemeResourceId(generalSettings.messageComposeTheme) @get:StyleRes val dialogThemeResourceId: Int = themeProvider.dialogThemeResourceId @@ -44,11 +58,17 @@ class ThemeManager( val translucentDialogThemeResourceId: Int = themeProvider.translucentDialogThemeResourceId fun init() { - updateAppTheme() + generalSettingsManager.getSettingsFlow() + .map { it.appTheme } + .distinctUntilChanged() + .onEach { + updateAppTheme(it) + } + .launchIn(appCoroutineScope + Dispatchers.Main) } - fun updateAppTheme() { - val defaultNightMode = when (K9.appTheme) { + private fun updateAppTheme(appTheme: AppTheme) { + val defaultNightMode = when (appTheme) { AppTheme.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO AppTheme.DARK -> AppCompatDelegate.MODE_NIGHT_YES AppTheme.FOLLOW_SYSTEM -> { @@ -64,12 +84,10 @@ class ThemeManager( fun toggleMessageViewTheme() { if (messageViewTheme === Theme.DARK) { - K9.messageViewTheme = SubTheme.LIGHT + generalSettingsManager.setMessageViewTheme(SubTheme.LIGHT) } else { - K9.messageViewTheme = SubTheme.DARK + generalSettingsManager.setMessageViewTheme(SubTheme.DARK) } - - K9.saveSettingsAsync() } private fun getSubThemeResourceId(subTheme: SubTheme): Int = when (subTheme) { diff --git a/app/ui/base/src/main/java/com/fsck/k9/ui/base/extensions/TextInputLayoutExtensions.kt b/app/ui/base/src/main/java/com/fsck/k9/ui/base/extensions/TextInputLayoutExtensions.kt new file mode 100644 index 0000000000000000000000000000000000000000..91b32d7d1d4c3f31d10522ffaf3c9da729169ab7 --- /dev/null +++ b/app/ui/base/src/main/java/com/fsck/k9/ui/base/extensions/TextInputLayoutExtensions.kt @@ -0,0 +1,119 @@ +@file:JvmName("TextInputLayoutHelper") + +package com.fsck.k9.ui.base.extensions + +import android.annotation.SuppressLint +import android.text.method.PasswordTransformationMethod +import android.view.WindowManager.LayoutParams.FLAG_SECURE +import android.widget.EditText +import android.widget.Toast +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.OnLifecycleEvent +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.google.android.material.textfield.TextInputLayout + +/** + * Configures a [TextInputLayout] so the password can only be revealed after authentication. + */ +fun TextInputLayout.configureAuthenticatedPasswordToggle( + activity: FragmentActivity, + title: String, + subtitle: String, + needScreenLockMessage: String, +) { + val viewModel = ViewModelProvider(activity).get(AuthenticatedPasswordToggleViewModel::class.java) + viewModel.textInputLayout = this + viewModel.activity = activity + + fun authenticateUserAndShowPassword(activity: FragmentActivity) { + val mainExecutor = ContextCompat.getMainExecutor(activity) + + val context = activity.applicationContext + val authenticationCallback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + // The Activity might have been recreated since this callback object was created (e.g. due to an + // orientation change). So we fetch the (new) references from the ViewModel. + viewModel.isAuthenticated = true + viewModel.activity?.setSecure(true) + viewModel.textInputLayout?.editText?.showPassword() + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + if (errorCode == BiometricPrompt.ERROR_HW_NOT_PRESENT || + errorCode == BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL || + errorCode == BiometricPrompt.ERROR_NO_BIOMETRICS + ) { + Toast.makeText(context, needScreenLockMessage, Toast.LENGTH_SHORT).show() + } else if (errString.isNotEmpty()) { + Toast.makeText(context, errString, Toast.LENGTH_SHORT).show() + } + } + } + + BiometricPrompt(activity, mainExecutor, authenticationCallback).authenticate( + BiometricPrompt.PromptInfo.Builder() + .setAllowedAuthenticators(BIOMETRIC_STRONG or BIOMETRIC_WEAK or DEVICE_CREDENTIAL) + .setTitle(title) + .setSubtitle(subtitle) + .build() + ) + } + + val editText = this.editText ?: error("TextInputLayout.editText == null") + + setEndIconOnClickListener { + if (editText.isPasswordHidden) { + if (viewModel.isAuthenticated) { + activity.setSecure(true) + editText.showPassword() + } else { + authenticateUserAndShowPassword(activity) + } + } else { + viewModel.isAuthenticated = false + editText.hidePassword() + activity.setSecure(false) + } + } +} + +private val EditText.isPasswordHidden: Boolean + get() = transformationMethod is PasswordTransformationMethod + +private fun EditText.showPassword() { + transformationMethod = null +} + +private fun EditText.hidePassword() { + transformationMethod = PasswordTransformationMethod.getInstance() +} + +private fun FragmentActivity.setSecure(secure: Boolean) { + window.setFlags(if (secure) FLAG_SECURE else 0, FLAG_SECURE) +} + +@SuppressLint("StaticFieldLeak") +class AuthenticatedPasswordToggleViewModel : ViewModel() { + var isAuthenticated = false + var textInputLayout: TextInputLayout? = null + var activity: FragmentActivity? = null + set(value) { + field = value + + value?.lifecycle?.addObserver(object : LifecycleObserver { + @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) + fun removeReferences() { + textInputLayout = null + field = null + } + }) + } +} diff --git a/app/ui/legacy/build.gradle b/app/ui/legacy/build.gradle index b6eb766b6300aa8d630ab4b1b863fb14518340bd..e997e6996978438cfdbc7b271d63b4505efa8830 100644 --- a/app/ui/legacy/build.gradle +++ b/app/ui/legacy/build.gradle @@ -21,15 +21,14 @@ dependencies { implementation "com.takisoft.preferencex:preferencex:${versions.preferencesFix}" implementation "com.takisoft.preferencex:preferencex-datetimepicker:${versions.preferencesFix}" 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}" implementation "androidx.cardview:cardview:${versions.androidxCardView}" implementation "androidx.localbroadcastmanager:localbroadcastmanager:${versions.androidxLocalBroadcastManager}" implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" - implementation "com.google.android.material:material:${versions.materialComponents}" implementation "de.cketti.library.changelog:ckchangelog-core:2.0.0-beta02" implementation "com.splitwise:tokenautocomplete:4.0.0-beta01" implementation "de.cketti.safecontentresolver:safe-content-resolver-v21:1.0.0" @@ -59,10 +58,13 @@ dependencies { testImplementation "org.robolectric:robolectric:${versions.robolectric}" testImplementation "androidx.test:core:${versions.androidxTestCore}" testImplementation "junit:junit:${versions.junit}" + testImplementation "org.jetbrains.kotlin:kotlin-test:${versions.kotlin}" testImplementation "com.google.truth:truth:${versions.truth}" testImplementation "org.mockito:mockito-core:${versions.mockito}" testImplementation "org.mockito.kotlin:mockito-kotlin:${versions.mockitoKotlin}" - testImplementation "org.koin:koin-test:${versions.koin}" + testImplementation "io.insert-koin:koin-test-junit4:${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 b44aade7d54be8b97c2690d74be1c57beb39c396..e60d813b96243318dc534f9122ff4e0a88a1bc91 100644 --- a/app/ui/legacy/src/main/AndroidManifest.xml +++ b/app/ui/legacy/src/main/AndroidManifest.xml @@ -5,4 +5,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 c3a29d12e8d1db34d9d67c274f3f1b8f85451d06..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().minBy { 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 6a7be838c71568ee6ad90ab16ba1d8d0dee78e0f..fd616150c3868459cfe00968221e414da87ab573 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 @@ -67,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; @@ -201,6 +203,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 @@ -211,6 +214,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, private RecipientPresenter recipientPresenter; private MessageBuilder currentMessageBuilder; + private ReplyToPresenter replyToPresenter; private boolean finishAfterDraftSaved; private boolean alreadyNotifiedUserOfEmptySubject = false; private boolean changesMadeSinceLastSave = false; @@ -306,6 +310,9 @@ public class MessageCompose extends K9Activity implements OnClickListener, chooseIdentityButton = findViewById(R.id.identity); chooseIdentityButton.setOnClickListener(this); + ReplyToView replyToView = new ReplyToView(this); + replyToPresenter = new ReplyToPresenter(replyToView); + RecipientMvpView recipientMvpView = new RecipientMvpView(this); ComposePgpInlineDecider composePgpInlineDecider = new ComposePgpInlineDecider(); ComposePgpEnableByDefaultDecider composePgpEnableByDefaultDecider = new ComposePgpEnableByDefaultDecider(); @@ -349,6 +356,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, } }; + replyToView.addTextChangedListener(draftNeedsChangingTextWatcher); recipientMvpView.addTextChangedListener(draftNeedsChangingTextWatcher); quotedMessageMvpView.addTextChangedListener(draftNeedsChangingTextWatcher); @@ -419,6 +427,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, requestReadReceipt = account.isMessageReadReceipt(); updateFrom(); + replyToPresenter.setIdentity(identity); if (!relatedMessageProcessed) { if (action == Action.REPLY || action == Action.REPLY_ALL || @@ -439,7 +448,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 || @@ -451,14 +462,11 @@ 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 int fontSize = K9.getFontSizes().getMessageComposeInput(); + replyToView.setFontSizes(K9.getFontSizes(), fontSize); recipientMvpView.setFontSizes(K9.getFontSizes(), fontSize); quotedMessageMvpView.setFontSizes(K9.getFontSizes(), fontSize); K9.getFontSizes().setViewTextSize(subjectView, fontSize); @@ -626,6 +634,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, outState.putBoolean(STATE_KEY_CHANGES_MADE_SINCE_LAST_SAVE, changesMadeSinceLastSave); outState.putBoolean(STATE_ALREADY_NOTIFIED_USER_OF_EMPTY_SUBJECT, alreadyNotifiedUserOfEmptySubject); + replyToPresenter.onSaveInstanceState(outState); recipientPresenter.onSaveInstanceState(outState); quotedMessagePresenter.onSaveInstanceState(outState); attachmentPresenter.onSaveInstanceState(outState); @@ -647,6 +656,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, requestReadReceipt = savedInstanceState.getBoolean(STATE_KEY_READ_RECEIPT); + replyToPresenter.onRestoreInstanceState(savedInstanceState); recipientPresenter.onRestoreInstanceState(savedInstanceState); quotedMessagePresenter.onRestoreInstanceState(savedInstanceState); attachmentPresenter.onRestoreInstanceState(savedInstanceState); @@ -710,6 +720,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, .setReferences(referencedMessageIds) .setRequestReadReceipt(requestReadReceipt) .setIdentity(identity) + .setReplyTo(replyToPresenter.getAddresses()) .setMessageFormat(currentMessageFormat) .setText(CrLfConverter.toCrLf(messageContentView.getText())) .setAttachments(attachmentPresenter.getAttachments()) @@ -735,6 +746,10 @@ public class MessageCompose extends K9Activity implements OnClickListener, return; } + if (replyToPresenter.isNotReadyForSending()) { + return; + } + if (recipientPresenter.checkRecipientsOkForSending()) { return; } @@ -920,6 +935,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, updateFrom(); updateSignature(); updateMessageFormat(); + replyToPresenter.setIdentity(identity); recipientPresenter.onSwitchIdentity(identity); } @@ -942,6 +958,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, int id = v.getId(); if (id == R.id.message_content || id == R.id.subject) { if (hasFocus) { + replyToPresenter.onNonRecipientFieldFocused(); recipientPresenter.onNonRecipientFieldFocused(); } } @@ -1355,6 +1372,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, draftMessageId = messagingController.getId(message); subjectView.setText(messageViewInfo.subject); + replyToPresenter.initFromDraftMessage(message); recipientPresenter.initFromDraftMessage(message); // Read In-Reply-To header from draft @@ -1430,6 +1448,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, updateSignature(); updateFrom(); + replyToPresenter.setIdentity(identity); quotedMessagePresenter.processDraftMessage(messageViewInfo, k9identity); } @@ -1443,10 +1462,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; @@ -1455,6 +1475,7 @@ public class MessageCompose extends K9Activity implements OnClickListener, this.draftId = draftId; this.plaintextSubject = plaintextSubject; this.messageReference = messageReference; + this.flag = flag; } @Override @@ -1463,7 +1484,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."); } @@ -1480,13 +1501,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); @@ -1580,7 +1600,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 6c0c0573267adecfc4c4d649d7f109c61405fde4..7c5e0965567839d5eb75252ea4d117d7e18ea4d7 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 @@ -19,6 +19,7 @@ import android.view.animation.AnimationUtils import android.widget.ProgressBar import android.widget.Toast import androidx.appcompat.app.ActionBar +import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.Toolbar import androidx.drawerlayout.widget.DrawerLayout import androidx.drawerlayout.widget.DrawerLayout.DrawerListener @@ -38,20 +39,21 @@ import com.fsck.k9.activity.setup.accountmanager.AccountManagerConstants.EELO_AC 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.controller.MessagingController import com.fsck.k9.fragment.MessageListFragment import com.fsck.k9.fragment.MessageListFragment.MessageListFragmentListener import com.fsck.k9.helper.Contacts -import com.fsck.k9.helper.EmailHelper import com.fsck.k9.helper.ParcelableUtil import com.fsck.k9.mailstore.SearchStatusManager -import com.fsck.k9.mailstore.StorageManager -import com.fsck.k9.mailstore.StorageManager.StorageListener + import com.fsck.k9.notification.NotificationChannelManager +import com.fsck.k9.preferences.GeneralSettingsManager import com.fsck.k9.search.LocalSearch import com.fsck.k9.search.SearchAccount import com.fsck.k9.search.SearchSpecification import com.fsck.k9.search.SearchSpecification.SearchCondition import com.fsck.k9.search.SearchSpecification.SearchField +import com.fsck.k9.search.isUnifiedInbox import com.fsck.k9.ui.BuildConfig import com.fsck.k9.ui.K9Drawer import com.fsck.k9.ui.R @@ -98,11 +100,14 @@ open class MessageList : private val channelUtils: NotificationChannelManager by inject() private val defaultFolderProvider: DefaultFolderProvider by inject() private val accountRemover: BackgroundAccountRemover by inject() + private val generalSettingsManager: GeneralSettingsManager by inject() + private val messagingController: MessagingController by inject() + - private val storageListener: StorageListener = StorageListenerImplementation() private val permissionUiHelper: PermissionUiHelper = K9PermissionUiHelper(this) private lateinit var actionBar: ActionBar + private lateinit var searchView: SearchView private var drawer: K9Drawer? = null private var openFolderTransaction: FragmentTransaction? = null private var menu: Menu? = null @@ -128,10 +133,9 @@ open class MessageList : private var messageReference: MessageReference? = null /** - * `true` when the message list was displayed once. This is used in - * [.onBackPressed] to decide whether to go from the message view to the message list or - * finish the activity. + * If this is `true`, only the message view will be displayed and pressing the back button will finish the Activity. */ + private var messageViewOnly = false private var messageListWasDisplayed = false private var viewSwitcher: ViewSwitcher? = null private lateinit var recentChangesSnackbar: Snackbar @@ -151,7 +155,6 @@ open class MessageList : accounts = preferences.accounts } - val hasAccountSetup = accounts.any { it.isFinishedSetup } if (!hasAccountSetup) { AccountSetupBasics.actionNewAccount(this) @@ -224,6 +227,12 @@ open class MessageList : return } + if (intent.action == Intent.ACTION_MAIN && intent.hasCategory(Intent.CATEGORY_LAUNCHER)) { + // There's nothing to do if the default launcher Intent was used. + // This only brings the existing screen to the foreground. + return + } + setIntent(intent) if (firstBackStackId >= 0) { @@ -348,6 +357,8 @@ open class MessageList : messageListFragment!!.setActiveMessage(activeMessage) } } + setDrawerLockState() + onMessageListDisplayed() } } } @@ -387,8 +398,11 @@ open class MessageList : launchData.search } - // Don't switch the currently active account when opening the Unified Inbox - val account = account?.takeIf { launchData.search.isUnifiedInbox } ?: search.firstAccount() + // If no account has been specified, keep the currently active account when opening the Unified Inbox + val account = launchData.account + ?: account?.takeIf { launchData.search.isUnifiedInbox } + ?: search.firstAccount() + if (account == null) { finish() return false @@ -399,11 +413,8 @@ open class MessageList : singleFolderMode = search.folderIds.size == 1 noThreading = launchData.noThreading messageReference = launchData.messageReference + messageViewOnly = launchData.messageViewOnly - if (!account.isAvailable(this)) { - onAccountUnavailable() - return false - } return true } @@ -420,11 +431,12 @@ 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(), - messageReference = messageReference + messageReference = messageReference, + messageViewOnly = true ) } } @@ -464,22 +476,31 @@ open class MessageList : search = search, noThreading = true ) - } else if (intent.hasExtra(EXTRA_SEARCH)) { - // regular LocalSearch object was passed - val search = ParcelableUtil.unmarshall(intent.getByteArrayExtra(EXTRA_SEARCH), LocalSearch.CREATOR) - val noThreading = intent.getBooleanExtra(EXTRA_NO_THREADING, false) - - return LaunchData(search = search, noThreading = noThreading) } else if (intent.hasExtra(EXTRA_MESSAGE_REFERENCE)) { val messageReferenceString = intent.getStringExtra(EXTRA_MESSAGE_REFERENCE) val messageReference = MessageReference.parse(messageReferenceString) if (messageReference != null) { + val search = if (intent.hasExtra(EXTRA_SEARCH)) { + ParcelableUtil.unmarshall(intent.getByteArrayExtra(EXTRA_SEARCH), LocalSearch.CREATOR) + } else { + messageReference.toLocalSearch() + } + return LaunchData( - search = messageReference.toLocalSearch(), + search = search, messageReference = messageReference ) } + } else if (intent.hasExtra(EXTRA_SEARCH)) { + // regular LocalSearch object was passed + val search = ParcelableUtil.unmarshall(intent.getByteArrayExtra(EXTRA_SEARCH), LocalSearch.CREATOR) + val noThreading = intent.getBooleanExtra(EXTRA_NO_THREADING, false) + val account = intent.getStringExtra(EXTRA_ACCOUNT)?.let { accountUuid -> + preferences.getAccount(accountUuid) + } + + return LaunchData(search = search, account = account, noThreading = noThreading) } else if (intent.hasExtra("account")) { val accountUuid = intent.getStringExtra("account") if (accountUuid != null) { @@ -524,32 +545,24 @@ open class MessageList : } } - public override fun onPause() { - super.onPause() - StorageManager.getInstance(application).removeListener(storageListener) - } - public override fun onResume() { super.onResume() if (messageListActivityAppearance == null) { - messageListActivityAppearance = MessageListActivityAppearance.create() - } else if (messageListActivityAppearance != MessageListActivityAppearance.create()) { + messageListActivityAppearance = MessageListActivityAppearance.create(generalSettingsManager) + } else if (messageListActivityAppearance != MessageListActivityAppearance.create(generalSettingsManager)) { recreate() } + if (displayMode != DisplayMode.MESSAGE_VIEW) { + onMessageListDisplayed() + } + if (this !is Search) { // necessary b/c no guarantee Search.onStop will be called before MessageList.onResume // when returning from search results searchStatusManager.isActive = false } - - if (account != null && !account!!.isAvailable(this)) { - onAccountUnavailable() - return - } - - StorageManager.getInstance(application).addListener(storageListener) } override fun onStart() { @@ -561,6 +574,7 @@ open class MessageList : super.onSaveInstanceState(outState) outState.putSerializable(STATE_DISPLAY_MODE, displayMode) + outState.putBoolean(STATE_MESSAGE_VIEW_ONLY, messageViewOnly) outState.putBoolean(STATE_MESSAGE_LIST_WAS_DISPLAYED, messageListWasDisplayed) outState.putInt(STATE_FIRST_BACK_STACK_ID, firstBackStackId) } @@ -568,6 +582,7 @@ open class MessageList : public override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) + messageViewOnly = savedInstanceState.getBoolean(STATE_MESSAGE_VIEW_ONLY) messageListWasDisplayed = savedInstanceState.getBoolean(STATE_MESSAGE_LIST_WAS_DISPLAYED) firstBackStackId = savedInstanceState.getInt(STATE_FIRST_BACK_STACK_ID) } @@ -615,6 +630,8 @@ open class MessageList : search.addAllowedFolder(folderId) performSearch(search) + + onMessageListDisplayed() } private fun openFolderImmediately(folderId: Long) { @@ -636,13 +653,17 @@ open class MessageList : ManageFoldersActivity.launch(this, account!!) } - fun openRealAccount(account: Account) { + fun openRealAccount(account: Account): Boolean { + val shouldCloseDrawer = account.autoExpandFolderId != null + val folderId = defaultFolderProvider.getDefaultFolder(account) val search = LocalSearch() search.addAllowedFolder(folderId) search.addAccountUuid(account.uuid) actionDisplaySearch(this, search, noThreading = false, newTask = false) + + return shouldCloseDrawer } private fun performSearch(search: LocalSearch) { @@ -666,8 +687,8 @@ open class MessageList : override fun dispatchKeyEvent(event: KeyEvent): Boolean { var eventHandled = false - if (KeyEvent.ACTION_DOWN == event.action) { - eventHandled = onCustomKeyDown(event.keyCode, event) + if (event.action == KeyEvent.ACTION_DOWN && searchView.isIconified) { + eventHandled = onCustomKeyDown(event) } if (!eventHandled) { @@ -680,8 +701,14 @@ open class MessageList : override fun onBackPressed() { if (isDrawerEnabled && drawer!!.isOpen) { drawer!!.close() - } else if (displayMode == DisplayMode.MESSAGE_VIEW && messageListWasDisplayed) { - showMessageList() + } else if (displayMode == DisplayMode.MESSAGE_VIEW) { + if (messageViewOnly) { + finish() + } else { + showMessageList() + } + } else if (this::searchView.isInitialized && !searchView.isIconified) { + searchView.isIconified = true } else { if (isDrawerEnabled && account != null && supportFragmentManager.backStackEntryCount == 0) { if (K9.isShowUnifiedInbox) { @@ -712,10 +739,10 @@ open class MessageList : * * @return `true` if this event was consumed. */ - private fun onCustomKeyDown(keyCode: Int, event: KeyEvent): Boolean { + private fun onCustomKeyDown(event: KeyEvent): Boolean { if (!event.hasNoModifiers()) return false - when (keyCode) { + when (event.keyCode) { KeyEvent.KEYCODE_VOLUME_UP -> { if (messageViewFragment != null && displayMode != DisplayMode.MESSAGE_LIST && K9.isUseVolumeKeysForNavigation @@ -738,31 +765,48 @@ open class MessageList : return true } } - KeyEvent.KEYCODE_C -> { + KeyEvent.KEYCODE_DEL -> { + onDeleteHotKey() + return true + } + KeyEvent.KEYCODE_DPAD_LEFT -> { + return if (messageViewFragment != null && displayMode == DisplayMode.MESSAGE_VIEW) { + showPreviousMessage() + } else { + false + } + } + KeyEvent.KEYCODE_DPAD_RIGHT -> { + return if (messageViewFragment != null && displayMode == DisplayMode.MESSAGE_VIEW) { + showNextMessage() + } else { + false + } + } + } + + when (if (event.unicodeChar != 0) event.unicodeChar.toChar() else null) { + 'c' -> { messageListFragment!!.onCompose() return true } - KeyEvent.KEYCODE_O -> { + 'o' -> { messageListFragment!!.onCycleSort() return true } - KeyEvent.KEYCODE_I -> { + 'i' -> { messageListFragment!!.onReverseSort() return true } - KeyEvent.KEYCODE_DEL, KeyEvent.KEYCODE_D -> { - if (displayMode == DisplayMode.MESSAGE_LIST) { - messageListFragment!!.onDelete() - } else if (messageViewFragment != null) { - messageViewFragment!!.onDelete() - } + 'd' -> { + onDeleteHotKey() return true } - KeyEvent.KEYCODE_S -> { + 's' -> { messageListFragment!!.toggleMessageSelect() return true } - KeyEvent.KEYCODE_G -> { + 'g' -> { if (displayMode == DisplayMode.MESSAGE_LIST) { messageListFragment!!.onToggleFlagged() } else if (messageViewFragment != null) { @@ -770,7 +814,7 @@ open class MessageList : } return true } - KeyEvent.KEYCODE_M -> { + 'm' -> { if (displayMode == DisplayMode.MESSAGE_LIST) { messageListFragment!!.onMove() } else if (messageViewFragment != null) { @@ -778,7 +822,7 @@ open class MessageList : } return true } - KeyEvent.KEYCODE_V -> { + 'v' -> { if (displayMode == DisplayMode.MESSAGE_LIST) { messageListFragment!!.onArchive() } else if (messageViewFragment != null) { @@ -786,7 +830,7 @@ open class MessageList : } return true } - KeyEvent.KEYCODE_Y -> { + 'y' -> { if (displayMode == DisplayMode.MESSAGE_LIST) { messageListFragment!!.onCopy() } else if (messageViewFragment != null) { @@ -794,7 +838,7 @@ open class MessageList : } return true } - KeyEvent.KEYCODE_Z -> { + 'z' -> { if (displayMode == DisplayMode.MESSAGE_LIST) { messageListFragment!!.onToggleRead() } else if (messageViewFragment != null) { @@ -802,37 +846,37 @@ open class MessageList : } return true } - KeyEvent.KEYCODE_F -> { + 'f' -> { if (messageViewFragment != null) { messageViewFragment!!.onForward() } return true } - KeyEvent.KEYCODE_A -> { + 'a' -> { if (messageViewFragment != null) { messageViewFragment!!.onReplyAll() } return true } - KeyEvent.KEYCODE_R -> { + 'r' -> { if (messageViewFragment != null) { messageViewFragment!!.onReply() } return true } - KeyEvent.KEYCODE_J, KeyEvent.KEYCODE_P -> { + 'j', 'p' -> { if (messageViewFragment != null) { showPreviousMessage() } return true } - KeyEvent.KEYCODE_N, KeyEvent.KEYCODE_K -> { + 'n', 'k' -> { if (messageViewFragment != null) { showNextMessage() } return true } - KeyEvent.KEYCODE_H -> { + 'h' -> { val toast = if (displayMode == DisplayMode.MESSAGE_LIST) { Toast.makeText(this, R.string.message_list_help_key, Toast.LENGTH_LONG) } else { @@ -841,25 +885,19 @@ open class MessageList : toast.show() return true } - KeyEvent.KEYCODE_DPAD_LEFT -> { - return if (messageViewFragment != null && displayMode == DisplayMode.MESSAGE_VIEW) { - showPreviousMessage() - } else { - false - } - } - KeyEvent.KEYCODE_DPAD_RIGHT -> { - return if (messageViewFragment != null && displayMode == DisplayMode.MESSAGE_VIEW) { - showNextMessage() - } else { - false - } - } } return false } + private fun onDeleteHotKey() { + if (displayMode == DisplayMode.MESSAGE_LIST) { + messageListFragment!!.onDelete() + } else if (messageViewFragment != null) { + messageViewFragment!!.onDelete() + } + } + override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { // Swallow these events too to avoid the audible notification of a volume change if (K9.isUseVolumeKeysForListNavigation) { @@ -872,10 +910,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) { @@ -923,9 +957,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 @@ -1021,6 +1052,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 } @@ -1077,7 +1127,7 @@ open class MessageList : } val toggleTheme = menu.findItem(R.id.toggle_message_view_theme) - if (K9.isFixedMessageViewTheme) { + if (generalSettingsManager.getSettings().fixedMessageViewTheme) { toggleTheme.isVisible = false } else { // Set title of menu item to switch to dark/light theme @@ -1102,7 +1152,6 @@ open class MessageList : } val typedArray = obtainStyledAttributes(drawableAttr) menu.findItem(R.id.toggle_unread).icon = typedArray.getDrawable(0) - menu.findItem(R.id.toggle_unread).icon.setTint(resources.getColor(getResId(R.attr.colorAccent))) typedArray.recycle() } @@ -1187,19 +1236,14 @@ open class MessageList : } } - protected fun onAccountUnavailable() { - // TODO: Find better way to handle this case. - Timber.i("Account is unavailable right now: $account") - finish() - } - - fun setActionBarTitle(title: String) { + fun setActionBarTitle(title: String, subtitle: String? = null) { actionBar.title = title + actionBar.subtitle = subtitle } - override fun setMessageListTitle(title: String) { + override fun setMessageListTitle(title: String, subtitle: String?) { if (displayMode != DisplayMode.MESSAGE_VIEW) { - setActionBarTitle(title) + setActionBarTitle(title, subtitle) } } @@ -1292,19 +1336,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 } @@ -1421,22 +1470,29 @@ open class MessageList : } private fun showMessageList() { + messageViewOnly = false messageListWasDisplayed = true displayMode = DisplayMode.MESSAGE_LIST viewSwitcher!!.showFirstView() messageListFragment!!.setActiveMessage(null) - if (isDrawerEnabled) { - if (isAdditionalMessageListDisplayed) { - lockDrawer() - } else { - unlockDrawer() - } - } + setDrawerLockState() showDefaultTitleView() configureMenu(menu) + + onMessageListDisplayed() + } + + private fun setDrawerLockState() { + if (!isDrawerEnabled) return + + if (isAdditionalMessageListDisplayed) { + lockDrawer() + } else { + unlockDrawer() + } } private fun showMessageView() { @@ -1484,6 +1540,14 @@ open class MessageList : } } + private fun onMessageListDisplayed() { + clearNotifications() + } + + private fun clearNotifications() { + messagingController.clearNotifications(search) + } + override fun startIntentSenderForResult( intent: IntentSender, requestCode: Int, @@ -1565,9 +1629,6 @@ open class MessageList : } } - private val LocalSearch.isUnifiedInbox: Boolean - get() = id == SearchAccount.UNIFIED_INBOX - private fun MessageReference.toLocalSearch(): LocalSearch { return LocalSearch().apply { addAccountUuid(accountUuid) @@ -1599,24 +1660,16 @@ open class MessageList : permissionUiHelper.requestPermission(permission) } - private inner class StorageListenerImplementation : StorageListener { - override fun onUnmount(providerId: String) { - if (account?.localStorageProviderId == providerId) { - runOnUiThread { onAccountUnavailable() } - } - } - - override fun onMount(providerId: String) = Unit - } - private enum class DisplayMode { MESSAGE_LIST, MESSAGE_VIEW, SPLIT_VIEW } private class LaunchData( val search: LocalSearch, + val account: Account? = null, val messageReference: MessageReference? = null, - val noThreading: Boolean = false + val noThreading: Boolean = false, + val messageViewOnly: Boolean = false ) private fun addNewAccountsAutomatically(accounts: List): Boolean { @@ -1681,6 +1734,7 @@ open class MessageList : private const val ACTION_SHORTCUT = "shortcut" private const val EXTRA_SPECIAL_FOLDER = "special_folder" + private const val EXTRA_ACCOUNT = "account_uuid" private const val EXTRA_MESSAGE_REFERENCE = "message_reference" // used for remote search @@ -1688,6 +1742,7 @@ open class MessageList : private const val EXTRA_SEARCH_FOLDER = "com.fsck.k9.search_folder" private const val STATE_DISPLAY_MODE = "displayMode" + private const val STATE_MESSAGE_VIEW_ONLY = "messageViewOnly" private const val STATE_MESSAGE_LIST_WAS_DISPLAYED = "messageListWasDisplayed" private const val STATE_FIRST_BACK_STACK_ID = "firstBackstackId" @@ -1734,6 +1789,30 @@ open class MessageList : } } + fun createUnifiedInboxIntent(context: Context, account: Account): Intent { + return Intent(context, MessageList::class.java).apply { + val search = SearchAccount.createUnifiedInboxAccount().relatedSearch + + putExtra(EXTRA_ACCOUNT, account.uuid) + putExtra(EXTRA_SEARCH, ParcelableUtil.marshall(search)) + putExtra(EXTRA_NO_THREADING, false) + + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + } + + fun createNewMessagesIntent(context: Context, account: Account): Intent { + val search = LocalSearch().apply { + id = SearchAccount.NEW_MESSAGES + addAccountUuid(account.uuid) + and(SearchField.NEW_MESSAGE, "1", SearchSpecification.Attribute.EQUALS) + } + + return intentDisplaySearch(context, search, noThreading = false, newTask = true, clearTop = true) + } + @JvmStatic fun shortcutIntent(context: Context?, specialFolder: String?): Intent { return Intent(context, MessageList::class.java).apply { @@ -1758,9 +1837,19 @@ open class MessageList : return intentDisplaySearch(context, search, noThreading = false, newTask = true, clearTop = true) } - @JvmStatic - fun actionDisplayMessageIntent(context: Context?, messageReference: MessageReference): Intent { + fun actionDisplayMessageIntent( + context: Context, + messageReference: MessageReference, + openInUnifiedInbox: Boolean = false + ): Intent { return Intent(context, MessageList::class.java).apply { + putExtra(EXTRA_MESSAGE_REFERENCE, messageReference.toIdentityString()) + + if (openInUnifiedInbox) { + val search = SearchAccount.createUnifiedInboxAccount().relatedSearch + putExtra(EXTRA_SEARCH, ParcelableUtil.marshall(search)) + } + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) putExtra(EXTRA_MESSAGE_REFERENCE, messageReference.toIdentityString()) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageListActivityAppearance.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageListActivityAppearance.kt index 8a8feb994b1e7deabca30bdaa4f8590beb6467e7..fac66de81fa667fa3a97f3763f9f953bdc310d1d 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageListActivityAppearance.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/MessageListActivityAppearance.kt @@ -1,9 +1,12 @@ package com.fsck.k9.activity import com.fsck.k9.K9 +import com.fsck.k9.preferences.AppTheme +import com.fsck.k9.preferences.GeneralSettingsManager +import com.fsck.k9.preferences.SubTheme data class MessageListActivityAppearance( - val appTheme: K9.AppTheme, + val appTheme: AppTheme, val isShowUnifiedInbox: Boolean, val isShowMessageListStars: Boolean, val isShowCorrespondentNames: Boolean, @@ -14,7 +17,7 @@ data class MessageListActivityAppearance( val isColorizeMissingContactPictures: Boolean, val isUseBackgroundAsUnreadIndicator: Boolean, val contactNameColor: Int, - val messageViewTheme: K9.SubTheme, + val messageViewTheme: SubTheme, val messageListPreviewLines: Int, val splitViewMode: K9.SplitViewMode, val fontSizeMessageListSubject: Int, @@ -32,33 +35,36 @@ data class MessageListActivityAppearance( ) { companion object { - fun create() = MessageListActivityAppearance( - appTheme = K9.appTheme, - isShowUnifiedInbox = K9.isShowUnifiedInbox, - isShowMessageListStars = K9.isShowMessageListStars, - isShowCorrespondentNames = K9.isShowCorrespondentNames, - isMessageListSenderAboveSubject = K9.isMessageListSenderAboveSubject, - isShowContactName = K9.isShowContactName, - isChangeContactNameColor = K9.isChangeContactNameColor, - isShowContactPicture = K9.isShowContactPicture, - isColorizeMissingContactPictures = K9.isColorizeMissingContactPictures, - isUseBackgroundAsUnreadIndicator = K9.isUseBackgroundAsUnreadIndicator, - contactNameColor = K9.contactNameColor, - messageViewTheme = K9.messageViewTheme, - messageListPreviewLines = K9.messageListPreviewLines, - splitViewMode = K9.splitViewMode, - fontSizeMessageListSubject = K9.fontSizes.messageListSubject, - fontSizeMessageListSender = K9.fontSizes.messageListSender, - fontSizeMessageListDate = K9.fontSizes.messageListDate, - fontSizeMessageListPreview = K9.fontSizes.messageListPreview, - fontSizeMessageViewSender = K9.fontSizes.messageViewSender, - fontSizeMessageViewTo = K9.fontSizes.messageViewTo, - fontSizeMessageViewCC = K9.fontSizes.messageViewCC, - fontSizeMessageViewBCC = K9.fontSizes.messageViewBCC, - fontSizeMessageViewAdditionalHeaders = K9.fontSizes.messageViewAdditionalHeaders, - fontSizeMessageViewSubject = K9.fontSizes.messageViewSubject, - fontSizeMessageViewDate = K9.fontSizes.messageViewDate, - fontSizeMessageViewContentAsPercent = K9.fontSizes.messageViewContentAsPercent - ) + fun create(generalSettingsManager: GeneralSettingsManager): MessageListActivityAppearance { + val settings = generalSettingsManager.getSettings() + return MessageListActivityAppearance( + appTheme = settings.appTheme, + isShowUnifiedInbox = K9.isShowUnifiedInbox, + isShowMessageListStars = K9.isShowMessageListStars, + isShowCorrespondentNames = K9.isShowCorrespondentNames, + isMessageListSenderAboveSubject = K9.isMessageListSenderAboveSubject, + isShowContactName = K9.isShowContactName, + isChangeContactNameColor = K9.isChangeContactNameColor, + isShowContactPicture = K9.isShowContactPicture, + isColorizeMissingContactPictures = K9.isColorizeMissingContactPictures, + isUseBackgroundAsUnreadIndicator = K9.isUseBackgroundAsUnreadIndicator, + contactNameColor = K9.contactNameColor, + messageViewTheme = settings.messageViewTheme, + messageListPreviewLines = K9.messageListPreviewLines, + splitViewMode = K9.splitViewMode, + fontSizeMessageListSubject = K9.fontSizes.messageListSubject, + fontSizeMessageListSender = K9.fontSizes.messageListSender, + fontSizeMessageListDate = K9.fontSizes.messageListDate, + fontSizeMessageListPreview = K9.fontSizes.messageListPreview, + fontSizeMessageViewSender = K9.fontSizes.messageViewSender, + fontSizeMessageViewTo = K9.fontSizes.messageViewTo, + fontSizeMessageViewCC = K9.fontSizes.messageViewCC, + fontSizeMessageViewBCC = K9.fontSizes.messageViewBCC, + fontSizeMessageViewAdditionalHeaders = K9.fontSizes.messageViewAdditionalHeaders, + fontSizeMessageViewSubject = K9.fontSizes.messageViewSubject, + fontSizeMessageViewDate = K9.fontSizes.messageViewDate, + fontSizeMessageViewContentAsPercent = K9.fontSizes.messageViewContentAsPercent + ) + } } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/IdentityAdapter.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/IdentityAdapter.java index ba8eafb8a9571d4b920f055a312073f4edeb0802..5ec89598165feb533e8329ae17c11b0b199d9431 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/IdentityAdapter.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/IdentityAdapter.java @@ -33,7 +33,7 @@ public class IdentityAdapter extends BaseAdapter { List items = new ArrayList<>(); Preferences prefs = Preferences.getPreferences(context.getApplicationContext()); - Collection accounts = prefs.getAvailableAccounts(); + Collection accounts = prefs.getAccounts(); for (Account account : accounts) { items.add(account); List identities = account.getIdentities(); 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.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientMvpView.java deleted file mode 100644 index 049c0279bc60136475a663eb6b119d6a41636b8f..0000000000000000000000000000000000000000 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientMvpView.java +++ /dev/null @@ -1,543 +0,0 @@ -package com.fsck.k9.activity.compose; - - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -import android.app.PendingIntent; -import androidx.loader.app.LoaderManager; -import androidx.interpolator.view.animation.FastOutLinearInInterpolator; -import androidx.interpolator.view.animation.LinearOutSlowInInterpolator; -import android.text.TextWatcher; -import android.view.View; -import android.view.View.OnClickListener; -import android.view.View.OnFocusChangeListener; -import android.widget.TextView; -import android.widget.Toast; -import android.widget.ViewAnimator; - -import com.fsck.k9.FontSizes; -import com.fsck.k9.ui.R; -import com.fsck.k9.activity.MessageCompose; -import com.fsck.k9.mail.Address; -import com.fsck.k9.mail.Message.RecipientType; -import com.fsck.k9.view.RecipientSelectView; -import com.fsck.k9.view.RecipientSelectView.Recipient; -import com.fsck.k9.view.RecipientSelectView.TokenListener; -import com.fsck.k9.view.ToolableViewAnimator; - -import static com.fsck.k9.FontSizes.FONT_10SP; -import static com.fsck.k9.FontSizes.FONT_12SP; -import static com.fsck.k9.FontSizes.FONT_16SP; -import static com.fsck.k9.FontSizes.FONT_20SP; -import static com.fsck.k9.FontSizes.FONT_DEFAULT; -import static com.fsck.k9.FontSizes.LARGE; -import static com.fsck.k9.FontSizes.MEDIUM; -import static com.fsck.k9.FontSizes.SMALL; - - -public class RecipientMvpView implements OnFocusChangeListener, OnClickListener { - private static final int VIEW_INDEX_HIDDEN = -1; - - private static final int VIEW_INDEX_BCC_EXPANDER_VISIBLE = 0; - private static final int VIEW_INDEX_BCC_EXPANDER_HIDDEN = 1; - - private static final FastOutLinearInInterpolator CRYPTO_ICON_OUT_ANIMATOR = new FastOutLinearInInterpolator(); - private static final int CRYPTO_ICON_OUT_DURATION = 195; - private static final LinearOutSlowInInterpolator CRYPTO_ICON_IN_ANIMATOR = new LinearOutSlowInInterpolator(); - private static final int CRYPTO_ICON_IN_DURATION = 225; - - private final MessageCompose activity; - private final View ccWrapper; - private final View ccDivider; - private final View bccWrapper; - private final View bccDivider; - private final RecipientSelectView toView; - private final RecipientSelectView ccView; - private final RecipientSelectView bccView; - private final ToolableViewAnimator cryptoStatusView; - private final ViewAnimator recipientExpanderContainer; - private final ToolableViewAnimator cryptoSpecialModeIndicator; - private final Set textWatchers = new HashSet<>(); - private RecipientPresenter presenter; - - - public RecipientMvpView(MessageCompose activity) { - this.activity = activity; - - toView = activity.findViewById(R.id.to); - ccView = activity.findViewById(R.id.cc); - bccView = activity.findViewById(R.id.bcc); - ccWrapper = activity.findViewById(R.id.cc_wrapper); - ccDivider = activity.findViewById(R.id.cc_divider); - bccWrapper = activity.findViewById(R.id.bcc_wrapper); - bccDivider = activity.findViewById(R.id.bcc_divider); - recipientExpanderContainer = activity.findViewById(R.id.recipient_expander_container); - cryptoStatusView = activity.findViewById(R.id.crypto_status); - cryptoStatusView.setOnClickListener(this); - cryptoSpecialModeIndicator = activity.findViewById(R.id.crypto_special_mode); - cryptoSpecialModeIndicator.setOnClickListener(this); - - toView.setOnFocusChangeListener(this); - ccView.setOnFocusChangeListener(this); - bccView.setOnFocusChangeListener(this); - - View recipientExpander = activity.findViewById(R.id.recipient_expander); - recipientExpander.setOnClickListener(this); - - View toLabel = activity.findViewById(R.id.to_label); - View ccLabel = activity.findViewById(R.id.cc_label); - View bccLabel = activity.findViewById(R.id.bcc_label); - toLabel.setOnClickListener(this); - ccLabel.setOnClickListener(this); - bccLabel.setOnClickListener(this); - } - - public void setPresenter(final RecipientPresenter presenter) { - this.presenter = presenter; - - if (presenter == null) { - toView.setTokenListener(null); - ccView.setTokenListener(null); - bccView.setTokenListener(null); - return; - } - - toView.setTokenListener(new TokenListener() { - @Override - public void onTokenAdded(Recipient recipient) { - presenter.onToTokenAdded(); - } - - @Override - public void onTokenRemoved(Recipient recipient) { - presenter.onToTokenRemoved(); - } - - @Override - public void onTokenChanged(Recipient recipient) { - presenter.onToTokenChanged(); - } - - @Override - public void onTokenIgnored(Recipient token) { - // Do nothing - } - }); - - ccView.setTokenListener(new TokenListener() { - @Override - public void onTokenAdded(Recipient recipient) { - presenter.onCcTokenAdded(); - } - - @Override - public void onTokenRemoved(Recipient recipient) { - presenter.onCcTokenRemoved(); - } - - @Override - public void onTokenChanged(Recipient recipient) { - presenter.onCcTokenChanged(); - } - - @Override - public void onTokenIgnored(Recipient token) { - // Do nothing - } - }); - - bccView.setTokenListener(new TokenListener() { - @Override - public void onTokenAdded(Recipient recipient) { - presenter.onBccTokenAdded(); - } - - @Override - public void onTokenRemoved(Recipient recipient) { - presenter.onBccTokenRemoved(); - } - - @Override - public void onTokenChanged(Recipient recipient) { - presenter.onBccTokenChanged(); - } - - @Override - public void onTokenIgnored(Recipient token) { - // Do nothing - } - }); - } - - public void addTextChangedListener(TextWatcher textWatcher) { - textWatchers.add(textWatcher); - - toView.addTextChangedListener(textWatcher); - ccView.addTextChangedListener(textWatcher); - bccView.addTextChangedListener(textWatcher); - } - - private void removeAllTextChangedListeners(TextView view) { - for (TextWatcher textWatcher : textWatchers) { - view.removeTextChangedListener(textWatcher); - } - } - - private void addAllTextChangedListeners(TextView view) { - for (TextWatcher textWatcher : textWatchers) { - view.addTextChangedListener(textWatcher); - } - } - - public void setRecipientTokensShowCryptoEnabled(boolean isEnabled) { - toView.setShowCryptoEnabled(isEnabled); - ccView.setShowCryptoEnabled(isEnabled); - bccView.setShowCryptoEnabled(isEnabled); - } - - public void setCryptoProvider(String openPgpProvider) { - // TODO move "show advanced" into settings, or somewhere? - toView.setCryptoProvider(openPgpProvider, false); - ccView.setCryptoProvider(openPgpProvider, false); - bccView.setCryptoProvider(openPgpProvider, false); - } - - public void requestFocusOnToField() { - toView.requestFocus(); - } - - public void requestFocusOnCcField() { - ccView.requestFocus(); - } - - public void requestFocusOnBccField() { - bccView.requestFocus(); - } - - public void setFontSizes(FontSizes fontSizes, int fontSize) { - int tokenTextSize = getTokenTextSize(fontSize); - toView.setTokenTextSize(tokenTextSize); - ccView.setTokenTextSize(tokenTextSize); - bccView.setTokenTextSize(tokenTextSize); - fontSizes.setViewTextSize(toView, fontSize); - fontSizes.setViewTextSize(ccView, fontSize); - fontSizes.setViewTextSize(bccView, fontSize); - } - - private int getTokenTextSize(int fontSize) { - switch (fontSize) { - case FONT_10SP: return FONT_10SP; - case FONT_12SP: return FONT_12SP; - case SMALL: return SMALL; - case FONT_16SP: return 15; - case MEDIUM: return FONT_16SP; - case FONT_20SP: return MEDIUM; - case LARGE: return FONT_20SP; - default: return FONT_DEFAULT; - } - } - - public void addRecipients(RecipientType recipientType, Recipient... recipients) { - switch (recipientType) { - case TO: { - toView.addRecipients(recipients); - break; - } - case CC: { - ccView.addRecipients(recipients); - break; - } - case BCC: { - bccView.addRecipients(recipients); - break; - } - } - } - - public void silentlyAddBccAddresses(Recipient... recipients) { - removeAllTextChangedListeners(bccView); - - bccView.addRecipients(recipients); - - addAllTextChangedListeners(bccView); - } - - public void silentlyRemoveBccAddresses(Address[] addressesToRemove) { - if (addressesToRemove.length == 0) { - return; - } - - List bccRecipients = new ArrayList<>(getBccRecipients()); - for (Recipient recipient : bccRecipients) { - removeAllTextChangedListeners(bccView); - - for (Address address : addressesToRemove) { - if (recipient.address.equals(address)) { - bccView.removeObjectSync(recipient); - } - } - - addAllTextChangedListeners(bccView); - } - } - - public void setCcVisibility(boolean visible) { - ccWrapper.setVisibility(visible ? View.VISIBLE : View.GONE); - ccDivider.setVisibility(visible ? View.VISIBLE : View.GONE); - } - - public void setBccVisibility(boolean visible) { - bccWrapper.setVisibility(visible ? View.VISIBLE : View.GONE); - bccDivider.setVisibility(visible ? View.VISIBLE : View.GONE); - } - - public void setRecipientExpanderVisibility(boolean visible) { - int childToDisplay = visible ? VIEW_INDEX_BCC_EXPANDER_VISIBLE : VIEW_INDEX_BCC_EXPANDER_HIDDEN; - if (recipientExpanderContainer.getDisplayedChild() != childToDisplay) { - recipientExpanderContainer.setDisplayedChild(childToDisplay); - } - } - - public boolean isCcVisible() { - return ccWrapper.getVisibility() == View.VISIBLE; - } - - public boolean isBccVisible() { - return bccWrapper.getVisibility() == View.VISIBLE; - } - - public void showNoRecipientsError() { - toView.setError(toView.getContext().getString(R.string.message_compose_error_no_recipients)); - } - - public List
getToAddresses() { - return Arrays.asList(toView.getAddresses()); - } - - public List
getCcAddresses() { - return Arrays.asList(ccView.getAddresses()); - } - - public List
getBccAddresses() { - return Arrays.asList(bccView.getAddresses()); - } - - public List getToRecipients() { - return toView.getObjects(); - } - - public List getCcRecipients() { - return ccView.getObjects(); - } - - public List getBccRecipients() { - return bccView.getObjects(); - } - - public boolean recipientToHasUncompletedText() { - return toView.hasUncompletedText(); - } - - public boolean recipientCcHasUncompletedText() { - return ccView.hasUncompletedText(); - } - - public boolean recipientBccHasUncompletedText() { - return bccView.hasUncompletedText(); - } - - public boolean recipientToTryPerformCompletion() { - return toView.tryPerformCompletion(); - } - - public boolean recipientCcTryPerformCompletion() { - return ccView.tryPerformCompletion(); - } - - public boolean recipientBccTryPerformCompletion() { - return bccView.tryPerformCompletion(); - } - - public void showToUncompletedError() { - toView.setError(toView.getContext().getString(R.string.compose_error_incomplete_recipient)); - } - - public void showCcUncompletedError() { - ccView.setError(ccView.getContext().getString(R.string.compose_error_incomplete_recipient)); - } - - public void showBccUncompletedError() { - bccView.setError(bccView.getContext().getString(R.string.compose_error_incomplete_recipient)); - } - - public void showCryptoSpecialMode(CryptoSpecialModeDisplayType cryptoSpecialModeDisplayType) { - boolean shouldBeHidden = cryptoSpecialModeDisplayType.childIdToDisplay == VIEW_INDEX_HIDDEN; - if (shouldBeHidden) { - cryptoSpecialModeIndicator.setVisibility(View.GONE); - return; - } - - cryptoSpecialModeIndicator.setVisibility(View.VISIBLE); - cryptoSpecialModeIndicator.setDisplayedChildId(cryptoSpecialModeDisplayType.childIdToDisplay); - activity.invalidateOptionsMenu(); - } - - public void showCryptoStatus(CryptoStatusDisplayType cryptoStatusDisplayType) { - boolean shouldBeHidden = cryptoStatusDisplayType.childIdToDisplay == VIEW_INDEX_HIDDEN; - if (shouldBeHidden) { - cryptoStatusView.animate() - .translationXBy(100.0f) - .alpha(0.0f) - .setDuration(CRYPTO_ICON_OUT_DURATION) - .setInterpolator(CRYPTO_ICON_OUT_ANIMATOR) - .start(); - return; - } - - cryptoStatusView.setVisibility(View.VISIBLE); - cryptoStatusView.setDisplayedChildId(cryptoStatusDisplayType.childIdToDisplay); - cryptoStatusView.animate() - .translationX(0.0f) - .alpha(1.0f) - .setDuration(CRYPTO_ICON_IN_DURATION) - .setInterpolator(CRYPTO_ICON_IN_ANIMATOR) - .start(); - } - - public void showContactPicker(int requestCode) { - activity.showContactPicker(requestCode); - } - - public void showErrorIsSignOnly() { - Toast.makeText(activity, R.string.error_sign_only_no_encryption, Toast.LENGTH_LONG).show(); - } - - public void showErrorContactNoAddress() { - Toast.makeText(activity, R.string.error_contact_address_not_found, Toast.LENGTH_LONG).show(); - } - - public void showErrorOpenPgpRetrieveStatus() { - Toast.makeText(activity, R.string.error_recipient_crypto_retrieve, Toast.LENGTH_LONG).show(); - } - - public void showErrorOpenPgpIncompatible() { - Toast.makeText(activity, R.string.error_crypto_provider_incompatible, Toast.LENGTH_LONG).show(); - } - - public void showErrorOpenPgpConnection() { - Toast.makeText(activity, R.string.error_crypto_provider_connect, Toast.LENGTH_LONG).show(); - } - - public void showErrorOpenPgpUserInteractionRequired() { - Toast.makeText(activity, R.string.error_crypto_provider_ui_required, Toast.LENGTH_LONG).show(); - } - - public void showErrorNoKeyConfigured() { - Toast.makeText(activity, R.string.compose_error_no_key_configured, Toast.LENGTH_LONG).show(); - } - - public void showErrorInlineAttach() { - Toast.makeText(activity, R.string.error_crypto_inline_attach, Toast.LENGTH_LONG).show(); - } - - @Override - public void onFocusChange(View view, boolean hasFocus) { - if (!hasFocus) { - return; - } - - int id = view.getId(); - if (id == R.id.to) { - presenter.onToFocused(); - } else if (id == R.id.cc) { - presenter.onCcFocused(); - } else if (id == R.id.bcc) { - presenter.onBccFocused(); - } - } - - @Override - public void onClick(View view) { - int id = view.getId(); - if (id == R.id.to_label) { - presenter.onClickToLabel(); - } else if (id == R.id.cc_label) { - presenter.onClickCcLabel(); - } else if (id == R.id.bcc_label) { - presenter.onClickBccLabel(); - } else if (id == R.id.recipient_expander) { - presenter.onClickRecipientExpander(); - } else if (id == R.id.crypto_status) { - presenter.onClickCryptoStatus(); - } else if (id == R.id.crypto_special_mode) { - presenter.onClickCryptoSpecialModeIndicator(); - } - } - - public void showOpenPgpInlineDialog(boolean firstTime) { - PgpInlineDialog dialog = PgpInlineDialog.newInstance(firstTime, R.id.crypto_special_mode); - dialog.show(activity.getSupportFragmentManager(), "openpgp_inline"); - } - - public void showOpenPgpSignOnlyDialog(boolean firstTime) { - PgpSignOnlyDialog dialog = PgpSignOnlyDialog.newInstance(firstTime, R.id.crypto_special_mode); - dialog.show(activity.getSupportFragmentManager(), "openpgp_signonly"); - } - - public void showOpenPgpEnabledErrorDialog(final boolean isGotItDialog) { - PgpEnabledErrorDialog dialog = PgpEnabledErrorDialog.newInstance(isGotItDialog, R.id.crypto_status_anchor); - dialog.show(activity.getSupportFragmentManager(), "openpgp_error"); - } - - public void showOpenPgpEncryptExplanationDialog() { - PgpEncryptDescriptionDialog dialog = PgpEncryptDescriptionDialog.newInstance(R.id.crypto_status_anchor); - dialog.show(activity.getSupportFragmentManager(), "openpgp_description"); - } - - public void launchUserInteractionPendingIntent(PendingIntent pendingIntent, int requestCode) { - activity.launchUserInteractionPendingIntent(pendingIntent, requestCode); - } - - public void setLoaderManager(LoaderManager loaderManager) { - toView.setLoaderManager(loaderManager); - ccView.setLoaderManager(loaderManager); - bccView.setLoaderManager(loaderManager); - } - - public enum CryptoStatusDisplayType { - 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); - - - final int childIdToDisplay; - - CryptoStatusDisplayType(int childIdToDisplay) { - this.childIdToDisplay = childIdToDisplay; - } - } - - public enum CryptoSpecialModeDisplayType { - NONE(VIEW_INDEX_HIDDEN), - PGP_INLINE(R.id.crypto_special_inline), - SIGN_ONLY(R.id.crypto_special_sign_only), - SIGN_ONLY_PGP_INLINE(R.id.crypto_special_sign_only_inline); - - - final int childIdToDisplay; - - CryptoSpecialModeDisplayType(int childIdToDisplay) { - this.childIdToDisplay = childIdToDisplay; - } - } -} 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 new file mode 100644 index 0000000000000000000000000000000000000000..60f5b84f62b902e206980874e5ccff7962e1b9fc --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientMvpView.kt @@ -0,0 +1,414 @@ +package com.fsck.k9.activity.compose + +import android.app.PendingIntent +import android.text.TextWatcher +import android.view.View +import android.widget.TextView +import android.widget.Toast +import android.widget.ViewAnimator +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.interpolator.view.animation.FastOutLinearInInterpolator +import androidx.interpolator.view.animation.LinearOutSlowInInterpolator +import androidx.loader.app.LoaderManager +import com.fsck.k9.FontSizes +import com.fsck.k9.activity.MessageCompose +import com.fsck.k9.mail.Address +import com.fsck.k9.mail.Message.RecipientType +import com.fsck.k9.ui.R +import com.fsck.k9.view.RecipientSelectView +import com.fsck.k9.view.RecipientSelectView.Recipient +import com.fsck.k9.view.ToolableViewAnimator +import java.lang.AssertionError + +class RecipientMvpView(private val activity: MessageCompose) : View.OnFocusChangeListener, View.OnClickListener { + private val toView: RecipientSelectView = activity.findViewById(R.id.to) + private val ccView: RecipientSelectView = activity.findViewById(R.id.cc) + private val bccView: RecipientSelectView = activity.findViewById(R.id.bcc) + private val ccWrapper: View = activity.findViewById(R.id.cc_wrapper) + private val ccDivider: View = activity.findViewById(R.id.cc_divider) + private val bccWrapper: View = activity.findViewById(R.id.bcc_wrapper) + private val bccDivider: View = activity.findViewById(R.id.bcc_divider) + private val recipientExpanderContainer: ViewAnimator = activity.findViewById(R.id.recipient_expander_container) + private val cryptoStatusView: ToolableViewAnimator = activity.findViewById(R.id.crypto_status) + private val cryptoSpecialModeIndicator: ToolableViewAnimator = activity.findViewById(R.id.crypto_special_mode) + private val textWatchers: MutableSet = HashSet() + private lateinit var presenter: RecipientPresenter + + init { + cryptoStatusView.setOnClickListener(this) + cryptoSpecialModeIndicator.setOnClickListener(this) + toView.onFocusChangeListener = this + ccView.onFocusChangeListener = this + bccView.onFocusChangeListener = this + + activity.findViewById(R.id.recipient_expander).setOnClickListener(this) + activity.findViewById(R.id.to_label).setOnClickListener(this) + activity.findViewById(R.id.cc_label).setOnClickListener(this) + activity.findViewById(R.id.bcc_label).setOnClickListener(this) + } + + val isCcVisible: Boolean + get() = ccWrapper.isVisible + + val isBccVisible: Boolean + get() = bccWrapper.isVisible + + val toAddresses: List
+ get() = toView.addresses.toList() + + val ccAddresses: List
+ get() = ccView.addresses.toList() + + val bccAddresses: List
+ get() = bccView.addresses.toList() + + val toRecipients: List + get() = toView.objects + + val ccRecipients: List + get() = ccView.objects + + val bccRecipients: List + get() = bccView.objects + + fun setPresenter(presenter: RecipientPresenter) { + 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() + + override fun onTokenIgnored(token: Recipient) = Unit + }) + + ccView.setTokenListener(object : RecipientSelectView.TokenListener { + override fun onTokenAdded(recipient: Recipient) = presenter.onCcTokenAdded() + + override fun onTokenRemoved(recipient: Recipient) = presenter.onCcTokenRemoved() + + override fun onTokenChanged(recipient: Recipient) = presenter.onCcTokenChanged() + + override fun onTokenIgnored(token: Recipient) = Unit + }) + + bccView.setTokenListener(object : RecipientSelectView.TokenListener { + override fun onTokenAdded(recipient: Recipient) = presenter.onBccTokenAdded() + + override fun onTokenRemoved(recipient: Recipient) = presenter.onBccTokenRemoved() + + override fun onTokenChanged(recipient: Recipient) = presenter.onBccTokenChanged() + + override fun onTokenIgnored(token: Recipient) = Unit + }) + } + + fun addTextChangedListener(textWatcher: TextWatcher) { + textWatchers.add(textWatcher) + toView.addTextChangedListener(textWatcher) + ccView.addTextChangedListener(textWatcher) + bccView.addTextChangedListener(textWatcher) + } + + private fun removeAllTextChangedListeners(view: TextView) { + for (textWatcher in textWatchers) { + view.removeTextChangedListener(textWatcher) + } + } + + private fun addAllTextChangedListeners(view: TextView) { + for (textWatcher in textWatchers) { + view.addTextChangedListener(textWatcher) + } + } + + fun setRecipientTokensShowCryptoEnabled(isEnabled: Boolean) { + toView.setShowCryptoEnabled(isEnabled) + ccView.setShowCryptoEnabled(isEnabled) + bccView.setShowCryptoEnabled(isEnabled) + } + + fun setCryptoProvider(openPgpProvider: String?) { + // TODO move "show advanced" into settings, or somewhere? + toView.setCryptoProvider(openPgpProvider, false) + ccView.setCryptoProvider(openPgpProvider, false) + bccView.setCryptoProvider(openPgpProvider, false) + } + + fun requestFocusOnToField() { + toView.requestFocus() + } + + fun requestFocusOnCcField() { + ccView.requestFocus() + } + + fun requestFocusOnBccField() { + bccView.requestFocus() + } + + fun setFontSizes(fontSizes: FontSizes, fontSize: Int) { + val tokenTextSize = getTokenTextSize(fontSize) + toView.setTokenTextSize(tokenTextSize) + ccView.setTokenTextSize(tokenTextSize) + bccView.setTokenTextSize(tokenTextSize) + fontSizes.setViewTextSize(toView, fontSize) + fontSizes.setViewTextSize(ccView, fontSize) + fontSizes.setViewTextSize(bccView, fontSize) + } + + private fun getTokenTextSize(fontSize: Int): Int { + return when (fontSize) { + FontSizes.FONT_10SP -> FontSizes.FONT_10SP + FontSizes.FONT_12SP -> FontSizes.FONT_12SP + FontSizes.SMALL -> FontSizes.SMALL + FontSizes.FONT_16SP -> 15 + FontSizes.MEDIUM -> FontSizes.FONT_16SP + FontSizes.FONT_20SP -> FontSizes.MEDIUM + FontSizes.LARGE -> FontSizes.FONT_20SP + else -> FontSizes.FONT_DEFAULT + } + } + + fun addRecipients(recipientType: RecipientType, vararg recipients: Recipient) { + when (recipientType) { + RecipientType.TO -> toView.addRecipients(*recipients) + RecipientType.CC -> ccView.addRecipients(*recipients) + RecipientType.BCC -> bccView.addRecipients(*recipients) + else -> throw AssertionError("Unsupported type: $recipientType") + } + } + + fun silentlyAddBccAddresses(vararg recipients: Recipient) { + removeAllTextChangedListeners(bccView) + + bccView.addRecipients(*recipients) + + addAllTextChangedListeners(bccView) + } + + fun silentlyRemoveBccAddresses(addresses: Array
) { + if (addresses.isEmpty()) return + + val addressesToRemove = addresses.toSet() + for (recipient in bccRecipients.toList()) { + removeAllTextChangedListeners(bccView) + + if (recipient.address in addressesToRemove) { + bccView.removeObjectSync(recipient) + } + + addAllTextChangedListeners(bccView) + } + } + + fun setCcVisibility(visible: Boolean) { + ccWrapper.isVisible = visible + ccDivider.isVisible = visible + } + + fun setBccVisibility(visible: Boolean) { + bccWrapper.isVisible = visible + bccDivider.isVisible = visible + } + + 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 + } + } + + fun showNoRecipientsError() { + toView.error = toView.context.getString(R.string.message_compose_error_no_recipients) + } + + fun recipientToHasUncompletedText(): Boolean { + return toView.hasUncompletedText() + } + + fun recipientCcHasUncompletedText(): Boolean { + return ccView.hasUncompletedText() + } + + fun recipientBccHasUncompletedText(): Boolean { + return bccView.hasUncompletedText() + } + + fun recipientToTryPerformCompletion(): Boolean { + return toView.tryPerformCompletion() + } + + fun recipientCcTryPerformCompletion(): Boolean { + return ccView.tryPerformCompletion() + } + + fun recipientBccTryPerformCompletion(): Boolean { + return bccView.tryPerformCompletion() + } + + fun showToUncompletedError() { + toView.error = toView.context.getString(R.string.compose_error_incomplete_recipient) + } + + fun showCcUncompletedError() { + ccView.error = ccView.context.getString(R.string.compose_error_incomplete_recipient) + } + + fun showBccUncompletedError() { + bccView.error = bccView.context.getString(R.string.compose_error_incomplete_recipient) + } + + fun showCryptoSpecialMode(cryptoSpecialModeDisplayType: CryptoSpecialModeDisplayType) { + val shouldBeHidden = cryptoSpecialModeDisplayType.childIdToDisplay == VIEW_INDEX_HIDDEN + if (shouldBeHidden) { + cryptoSpecialModeIndicator.isGone = true + return + } + + cryptoSpecialModeIndicator.isVisible = true + cryptoSpecialModeIndicator.displayedChildId = cryptoSpecialModeDisplayType.childIdToDisplay + + activity.invalidateOptionsMenu() + } + + fun showCryptoStatus(cryptoStatusDisplayType: CryptoStatusDisplayType) { + val shouldBeHidden = cryptoStatusDisplayType.childIdToDisplay == VIEW_INDEX_HIDDEN + if (shouldBeHidden) { + cryptoStatusView.animate() + .translationXBy(100.0f) + .alpha(0.0f) + .setDuration(CRYPTO_ICON_OUT_DURATION.toLong()) + .setInterpolator(CRYPTO_ICON_OUT_ANIMATOR) + .start() + + return + } + + cryptoStatusView.isVisible = true + cryptoStatusView.displayedChildId = cryptoStatusDisplayType.childIdToDisplay + cryptoStatusView.animate() + .translationX(0.0f) + .alpha(1.0f) + .setDuration(CRYPTO_ICON_IN_DURATION.toLong()) + .setInterpolator(CRYPTO_ICON_IN_ANIMATOR) + .start() + } + + fun showContactPicker(requestCode: Int) { + activity.showContactPicker(requestCode) + } + + fun showErrorIsSignOnly() { + Toast.makeText(activity, R.string.error_sign_only_no_encryption, Toast.LENGTH_LONG).show() + } + + fun showErrorContactNoAddress() { + Toast.makeText(activity, R.string.error_contact_address_not_found, Toast.LENGTH_LONG).show() + } + + fun showErrorOpenPgpIncompatible() { + Toast.makeText(activity, R.string.error_crypto_provider_incompatible, Toast.LENGTH_LONG).show() + } + + fun showErrorOpenPgpConnection() { + Toast.makeText(activity, R.string.error_crypto_provider_connect, Toast.LENGTH_LONG).show() + } + + fun showErrorOpenPgpUserInteractionRequired() { + Toast.makeText(activity, R.string.error_crypto_provider_ui_required, Toast.LENGTH_LONG).show() + } + + fun showErrorNoKeyConfigured() { + Toast.makeText(activity, R.string.compose_error_no_key_configured, Toast.LENGTH_LONG).show() + } + + fun showErrorInlineAttach() { + Toast.makeText(activity, R.string.error_crypto_inline_attach, Toast.LENGTH_LONG).show() + } + + override fun onFocusChange(view: View, hasFocus: Boolean) { + if (!hasFocus) return + + when (view.id) { + R.id.to -> presenter.onToFocused() + R.id.cc -> presenter.onCcFocused() + R.id.bcc -> presenter.onBccFocused() + } + } + + override fun onClick(view: View) { + when (view.id) { + R.id.to_label -> presenter.onClickToLabel() + R.id.cc_label -> presenter.onClickCcLabel() + R.id.bcc_label -> presenter.onClickBccLabel() + R.id.recipient_expander -> presenter.onClickRecipientExpander() + R.id.crypto_status -> presenter.onClickCryptoStatus() + R.id.crypto_special_mode -> presenter.onClickCryptoSpecialModeIndicator() + } + } + + fun showOpenPgpInlineDialog(firstTime: Boolean) { + val dialog = PgpInlineDialog.newInstance(firstTime, R.id.crypto_special_mode) + dialog.show(activity.supportFragmentManager, "openpgp_inline") + } + + fun showOpenPgpSignOnlyDialog(firstTime: Boolean) { + val dialog = PgpSignOnlyDialog.newInstance(firstTime, R.id.crypto_special_mode) + dialog.show(activity.supportFragmentManager, "openpgp_signonly") + } + + fun showOpenPgpEnabledErrorDialog(isGotItDialog: Boolean) { + val dialog = PgpEnabledErrorDialog.newInstance(isGotItDialog, R.id.crypto_status_anchor) + dialog.show(activity.supportFragmentManager, "openpgp_error") + } + + fun showOpenPgpEncryptExplanationDialog() { + val dialog = PgpEncryptDescriptionDialog.newInstance(R.id.crypto_status_anchor) + dialog.show(activity.supportFragmentManager, "openpgp_description") + } + + fun launchUserInteractionPendingIntent(pendingIntent: PendingIntent?, requestCode: Int) { + activity.launchUserInteractionPendingIntent(pendingIntent, requestCode) + } + + fun setLoaderManager(loaderManager: LoaderManager?) { + toView.setLoaderManager(loaderManager) + ccView.setLoaderManager(loaderManager) + bccView.setLoaderManager(loaderManager) + } + + enum class CryptoStatusDisplayType(val childIdToDisplay: Int) { + 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); + } + + enum class CryptoSpecialModeDisplayType(val childIdToDisplay: Int) { + NONE(VIEW_INDEX_HIDDEN), + PGP_INLINE(R.id.crypto_special_inline), + SIGN_ONLY(R.id.crypto_special_sign_only), + SIGN_ONLY_PGP_INLINE(R.id.crypto_special_sign_only_inline); + } + + companion object { + private const val VIEW_INDEX_HIDDEN = -1 + private const val VIEW_INDEX_BCC_EXPANDER_VISIBLE = 0 + private const val VIEW_INDEX_BCC_EXPANDER_HIDDEN = 1 + + private val CRYPTO_ICON_OUT_ANIMATOR = FastOutLinearInInterpolator() + private const val CRYPTO_ICON_OUT_DURATION = 195 + + private val CRYPTO_ICON_IN_ANIMATOR = LinearOutSlowInInterpolator() + private const val CRYPTO_ICON_IN_DURATION = 225 + } +} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientPresenter.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientPresenter.java deleted file mode 100644 index 7aaf4a8b3e94657c35db06b1c5b288d080d61c01..0000000000000000000000000000000000000000 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientPresenter.java +++ /dev/null @@ -1,886 +0,0 @@ -package com.fsck.k9.activity.compose; - - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -import android.Manifest; -import android.app.Activity; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.net.Uri; -import android.os.AsyncTask; -import android.os.Bundle; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; -import androidx.loader.app.LoaderManager; -import android.view.Menu; - -import com.fsck.k9.Account; -import com.fsck.k9.Identity; -import com.fsck.k9.K9; -import com.fsck.k9.activity.compose.ComposeCryptoStatus.AttachErrorState; -import com.fsck.k9.activity.compose.ComposeCryptoStatus.SendErrorState; -import com.fsck.k9.activity.compose.RecipientMvpView.CryptoStatusDisplayType; -import com.fsck.k9.autocrypt.AutocryptDraftStateHeader; -import com.fsck.k9.autocrypt.AutocryptDraftStateHeaderParser; -import com.fsck.k9.helper.Contacts; -import com.fsck.k9.helper.MailTo; -import com.fsck.k9.helper.ReplyToParser; -import com.fsck.k9.helper.ReplyToParser.ReplyToAddresses; -import com.fsck.k9.mail.Address; -import com.fsck.k9.mail.Flag; -import com.fsck.k9.mail.Message; -import com.fsck.k9.mail.Message.RecipientType; -import com.fsck.k9.message.AutocryptStatusInteractor; -import com.fsck.k9.message.AutocryptStatusInteractor.RecipientAutocryptStatus; -import com.fsck.k9.message.ComposePgpEnableByDefaultDecider; -import com.fsck.k9.message.ComposePgpInlineDecider; -import com.fsck.k9.message.MessageBuilder; -import com.fsck.k9.message.PgpMessageBuilder; -import com.fsck.k9.ui.R; -import com.fsck.k9.view.RecipientSelectView.Recipient; -import org.openintents.openpgp.OpenPgpApiManager; -import org.openintents.openpgp.OpenPgpApiManager.OpenPgpApiManagerCallback; -import org.openintents.openpgp.OpenPgpApiManager.OpenPgpProviderError; -import org.openintents.openpgp.OpenPgpApiManager.OpenPgpProviderState; -import org.openintents.openpgp.util.OpenPgpApi; -import timber.log.Timber; - - -public class RecipientPresenter { - private static final String STATE_KEY_CC_SHOWN = "state:ccShown"; - private static final String STATE_KEY_BCC_SHOWN = "state:bccShown"; - private static final String STATE_KEY_LAST_FOCUSED_TYPE = "state:lastFocusedType"; - private static final String STATE_KEY_CURRENT_CRYPTO_MODE = "state:currentCryptoMode"; - private static final String STATE_KEY_CRYPTO_ENABLE_PGP_INLINE = "state:cryptoEnablePgpInline"; - - private static final int CONTACT_PICKER_TO = 1; - private static final int CONTACT_PICKER_CC = 2; - private static final int CONTACT_PICKER_BCC = 3; - private static final int OPENPGP_USER_INTERACTION = 4; - private static final int REQUEST_CODE_AUTOCRYPT = 5; - - private static final int PGP_DIALOG_DISPLAY_THRESHOLD = 2; - - - // transient state, which is either obtained during construction and initialization, or cached - private final Context context; - private final RecipientMvpView recipientMvpView; - private final ComposePgpEnableByDefaultDecider composePgpEnableByDefaultDecider; - private final ComposePgpInlineDecider composePgpInlineDecider; - private final AutocryptStatusInteractor autocryptStatusInteractor; - private final OpenPgpApiManager openPgpApiManager; - private final AutocryptDraftStateHeaderParser draftStateHeaderParser; - private ReplyToParser replyToParser; - private Account account; - private Address[] alwaysBccAddresses; - private Boolean hasContactPicker; - @Nullable - private ComposeCryptoStatus cachedCryptoStatus; - - - // persistent state, saved during onSaveInstanceState - private RecipientType lastFocusedType = RecipientType.TO; - private CryptoMode currentCryptoMode = CryptoMode.NO_CHOICE; - private boolean cryptoEnablePgpInline = false; - private boolean isReplyToEncryptedMessage = false; - - - public RecipientPresenter(Context context, LoaderManager loaderManager, - OpenPgpApiManager openPgpApiManager, RecipientMvpView recipientMvpView, Account account, - ComposePgpInlineDecider composePgpInlineDecider, - ComposePgpEnableByDefaultDecider composePgpEnableByDefaultDecider, - AutocryptStatusInteractor autocryptStatusInteractor, - ReplyToParser replyToParser, AutocryptDraftStateHeaderParser draftStateHeaderParser) { - this.recipientMvpView = recipientMvpView; - this.context = context; - this.autocryptStatusInteractor = autocryptStatusInteractor; - this.composePgpInlineDecider = composePgpInlineDecider; - this.composePgpEnableByDefaultDecider = composePgpEnableByDefaultDecider; - this.replyToParser = replyToParser; - this.openPgpApiManager = openPgpApiManager; - this.draftStateHeaderParser = draftStateHeaderParser; - - recipientMvpView.setPresenter(this); - recipientMvpView.setLoaderManager(loaderManager); - onSwitchAccount(account); - } - - public List
getToAddresses() { - return recipientMvpView.getToAddresses(); - } - - public List
getCcAddresses() { - return recipientMvpView.getCcAddresses(); - } - - public List
getBccAddresses() { - return recipientMvpView.getBccAddresses(); - } - - private List getAllRecipients() { - ArrayList result = new ArrayList<>(); - - result.addAll(recipientMvpView.getToRecipients()); - result.addAll(recipientMvpView.getCcRecipients()); - result.addAll(recipientMvpView.getBccRecipients()); - - return result; - } - - public boolean checkRecipientsOkForSending() { - recipientMvpView.recipientToTryPerformCompletion(); - recipientMvpView.recipientCcTryPerformCompletion(); - recipientMvpView.recipientBccTryPerformCompletion(); - - if (recipientMvpView.recipientToHasUncompletedText()) { - recipientMvpView.showToUncompletedError(); - return true; - } - - if (recipientMvpView.recipientCcHasUncompletedText()) { - recipientMvpView.showCcUncompletedError(); - return true; - } - - if (recipientMvpView.recipientBccHasUncompletedText()) { - recipientMvpView.showBccUncompletedError(); - return true; - } - - if (getToAddresses().isEmpty() && getCcAddresses().isEmpty() && getBccAddresses().isEmpty()) { - recipientMvpView.showNoRecipientsError(); - return true; - } - - return false; - } - - public void initFromReplyToMessage(Message message, boolean isReplyAll) { - ReplyToAddresses replyToAddresses = isReplyAll ? - replyToParser.getRecipientsToReplyAllTo(message, account) : - replyToParser.getRecipientsToReplyTo(message, account); - - addToAddresses(replyToAddresses.to); - addCcAddresses(replyToAddresses.cc); - - boolean shouldSendAsPgpInline = composePgpInlineDecider.shouldReplyInline(message); - if (shouldSendAsPgpInline) { - cryptoEnablePgpInline = true; - } - - isReplyToEncryptedMessage = composePgpEnableByDefaultDecider.shouldEncryptByDefault(message); - } - - public void initFromTrustIdAction(String trustId) { - addToAddresses(Address.parse(trustId)); - currentCryptoMode = CryptoMode.CHOICE_ENABLED; - } - - public void initFromMailto(MailTo mailTo) { - addToAddresses(mailTo.getTo()); - addCcAddresses(mailTo.getCc()); - addBccAddresses(mailTo.getBcc()); - } - - public void initFromSendOrViewIntent(Intent intent) { - String[] extraEmail = intent.getStringArrayExtra(Intent.EXTRA_EMAIL); - String[] extraCc = intent.getStringArrayExtra(Intent.EXTRA_CC); - String[] extraBcc = intent.getStringArrayExtra(Intent.EXTRA_BCC); - - if (extraEmail != null) { - addToAddresses(addressFromStringArray(extraEmail)); - } - - if (extraCc != null) { - addCcAddresses(addressFromStringArray(extraCc)); - } - - if (extraBcc != null) { - addBccAddresses(addressFromStringArray(extraBcc)); - } - } - - public void onRestoreInstanceState(Bundle savedInstanceState) { - recipientMvpView.setCcVisibility(savedInstanceState.getBoolean(STATE_KEY_CC_SHOWN)); - recipientMvpView.setBccVisibility(savedInstanceState.getBoolean(STATE_KEY_BCC_SHOWN)); - lastFocusedType = RecipientType.valueOf(savedInstanceState.getString(STATE_KEY_LAST_FOCUSED_TYPE)); - currentCryptoMode = CryptoMode.valueOf(savedInstanceState.getString(STATE_KEY_CURRENT_CRYPTO_MODE)); - cryptoEnablePgpInline = savedInstanceState.getBoolean(STATE_KEY_CRYPTO_ENABLE_PGP_INLINE); - updateRecipientExpanderVisibility(); - } - - public void onSaveInstanceState(Bundle outState) { - outState.putBoolean(STATE_KEY_CC_SHOWN, recipientMvpView.isCcVisible()); - outState.putBoolean(STATE_KEY_BCC_SHOWN, recipientMvpView.isBccVisible()); - outState.putString(STATE_KEY_LAST_FOCUSED_TYPE, lastFocusedType.toString()); - outState.putString(STATE_KEY_CURRENT_CRYPTO_MODE, currentCryptoMode.toString()); - outState.putBoolean(STATE_KEY_CRYPTO_ENABLE_PGP_INLINE, cryptoEnablePgpInline); - } - - public void initFromDraftMessage(Message message) { - initRecipientsFromDraftMessage(message); - - String[] draftStateHeader = message.getHeader(AutocryptDraftStateHeader.AUTOCRYPT_DRAFT_STATE_HEADER); - if (draftStateHeader.length == 1) { - initEncryptionStateFromDraftStateHeader(draftStateHeader[0]); - } else { - initPgpInlineFromDraftMessage(message); - } - } - - private void initEncryptionStateFromDraftStateHeader(String headerValue) { - AutocryptDraftStateHeader autocryptDraftStateHeader = - draftStateHeaderParser.parseAutocryptDraftStateHeader(headerValue); - if (autocryptDraftStateHeader != null) { - initEncryptionStateFromDraftStateHeader(autocryptDraftStateHeader); - } - } - - private void initRecipientsFromDraftMessage(Message message) { - addToAddresses(message.getRecipients(RecipientType.TO)); - - Address[] ccRecipients = message.getRecipients(RecipientType.CC); - addCcAddresses(ccRecipients); - - Address[] bccRecipients = message.getRecipients(RecipientType.BCC); - addBccAddresses(bccRecipients); - } - - private void initEncryptionStateFromDraftStateHeader(AutocryptDraftStateHeader draftState) { - cryptoEnablePgpInline = draftState.isPgpInline(); - isReplyToEncryptedMessage = draftState.isReply(); - if (!draftState.isByChoice()) { - // TODO if it's not by choice, we're going with our defaults. should we do something here if those differ? - return; - } - - if (draftState.isSignOnly()) { - currentCryptoMode = CryptoMode.SIGN_ONLY; - } else { - currentCryptoMode = draftState.isEncrypt() ? CryptoMode.CHOICE_ENABLED : CryptoMode.CHOICE_DISABLED; - } - } - - private void initPgpInlineFromDraftMessage(Message message) { - cryptoEnablePgpInline = message.isSet(Flag.X_DRAFT_OPENPGP_INLINE); - } - - private void addToAddresses(Address... toAddresses) { - addRecipientsFromAddresses(RecipientType.TO, toAddresses); - } - - private void addCcAddresses(Address... ccAddresses) { - if (ccAddresses.length > 0) { - addRecipientsFromAddresses(RecipientType.CC, ccAddresses); - recipientMvpView.setCcVisibility(true); - updateRecipientExpanderVisibility(); - } - } - - public void addBccAddresses(Address... bccRecipients) { - if (bccRecipients.length > 0) { - addRecipientsFromAddresses(RecipientType.BCC, bccRecipients); - recipientMvpView.setBccVisibility(true); - updateRecipientExpanderVisibility(); - } - } - - public void addAlwaysBcc() { - alwaysBccAddresses = Address.parse(account.getAlwaysBcc()); - - new RecipientLoader(context, account.getOpenPgpProvider(), alwaysBccAddresses) { - @Override - public void deliverResult(List result) { - Recipient[] recipientArray = result.toArray(new Recipient[result.size()]); - recipientMvpView.silentlyAddBccAddresses(recipientArray); - - stopLoading(); - abandon(); - } - }.startLoading(); - } - - private void removeAlwaysBcc() { - if (alwaysBccAddresses != null) { - recipientMvpView.silentlyRemoveBccAddresses(alwaysBccAddresses); - } - } - - public void onPrepareOptionsMenu(Menu menu) { - ComposeCryptoStatus currentCryptoStatus = getCurrentCachedCryptoStatus(); - boolean isCryptoConfigured = currentCryptoStatus != null && currentCryptoStatus.isProviderStateOk(); - if (isCryptoConfigured) { - boolean isEncrypting = currentCryptoStatus.isEncryptionEnabled(); - menu.findItem(R.id.openpgp_encrypt_enable).setVisible(!isEncrypting); - menu.findItem(R.id.openpgp_encrypt_disable).setVisible(isEncrypting); - - boolean showSignOnly = !account.isOpenPgpHideSignOnly(); - boolean isSignOnly = currentCryptoStatus.isSignOnly(); - menu.findItem(R.id.openpgp_sign_only).setVisible(showSignOnly && !isSignOnly); - menu.findItem(R.id.openpgp_sign_only_disable).setVisible(showSignOnly && isSignOnly); - - boolean pgpInlineModeEnabled = currentCryptoStatus.isPgpInlineModeEnabled(); - boolean showPgpInlineEnable = (isEncrypting || isSignOnly) && !pgpInlineModeEnabled; - menu.findItem(R.id.openpgp_inline_enable).setVisible(showPgpInlineEnable); - menu.findItem(R.id.openpgp_inline_disable).setVisible(pgpInlineModeEnabled); - } else { - menu.findItem(R.id.openpgp_inline_enable).setVisible(false); - menu.findItem(R.id.openpgp_inline_disable).setVisible(false); - menu.findItem(R.id.openpgp_encrypt_enable).setVisible(false); - menu.findItem(R.id.openpgp_encrypt_disable).setVisible(false); - menu.findItem(R.id.openpgp_sign_only).setVisible(false); - menu.findItem(R.id.openpgp_sign_only_disable).setVisible(false); - } - - menu.findItem(R.id.add_from_contacts).setVisible(hasContactPicker() && hasContactPermission()); - } - - public void onSwitchAccount(Account account) { - this.account = account; - - if (account.isAlwaysShowCcBcc()) { - recipientMvpView.setCcVisibility(true); - recipientMvpView.setBccVisibility(true); - updateRecipientExpanderVisibility(); - } - - removeAlwaysBcc(); - addAlwaysBcc(); - - String openPgpProvider = account.getOpenPgpProvider(); - recipientMvpView.setCryptoProvider(openPgpProvider); - openPgpApiManager.setOpenPgpProvider(openPgpProvider, openPgpCallback); - } - - @SuppressWarnings("UnusedParameters") - public void onSwitchIdentity(Identity identity) { - - // TODO decide what actually to do on identity switch? - asyncUpdateCryptoStatus(); - /* - if (mIdentityChanged) { - mBccWrapper.setVisibility(View.VISIBLE); - } - mBccView.setText(""); - mBccView.addAddress(new Address(mAccount.getAlwaysBcc(), "")); - */ - - } - - private static Address[] addressFromStringArray(String[] addresses) { - return addressFromStringArray(Arrays.asList(addresses)); - } - - private static Address[] addressFromStringArray(List addresses) { - ArrayList
result = new ArrayList<>(addresses.size()); - - for (String addressStr : addresses) { - Collections.addAll(result, Address.parseUnencoded(addressStr)); - } - - return result.toArray(new Address[result.size()]); - } - - void onClickToLabel() { - recipientMvpView.requestFocusOnToField(); - } - - void onClickCcLabel() { - recipientMvpView.requestFocusOnCcField(); - } - - void onClickBccLabel() { - recipientMvpView.requestFocusOnBccField(); - } - - void onClickRecipientExpander() { - recipientMvpView.setCcVisibility(true); - recipientMvpView.setBccVisibility(true); - updateRecipientExpanderVisibility(); - } - - private void hideEmptyExtendedRecipientFields() { - if (recipientMvpView.getCcAddresses().isEmpty()) { - recipientMvpView.setCcVisibility(false); - if (lastFocusedType == RecipientType.CC) { - lastFocusedType = RecipientType.TO; - } - } - if (recipientMvpView.getBccAddresses().isEmpty()) { - recipientMvpView.setBccVisibility(false); - if (lastFocusedType == RecipientType.BCC) { - lastFocusedType = RecipientType.TO; - } - } - updateRecipientExpanderVisibility(); - } - - private void updateRecipientExpanderVisibility() { - boolean notBothAreVisible = !(recipientMvpView.isCcVisible() && recipientMvpView.isBccVisible()); - recipientMvpView.setRecipientExpanderVisibility(notBothAreVisible); - } - - public void asyncUpdateCryptoStatus() { - cachedCryptoStatus = null; - - OpenPgpProviderState openPgpProviderState = openPgpApiManager.getOpenPgpProviderState(); - - Long accountCryptoKey = account.getOpenPgpKey(); - if (accountCryptoKey == Account.NO_OPENPGP_KEY) { - accountCryptoKey = null; - } - - final ComposeCryptoStatus composeCryptoStatus = new ComposeCryptoStatus( - openPgpProviderState, - accountCryptoKey, - getAllRecipients(), - cryptoEnablePgpInline, - account.getAutocryptPreferEncryptMutual(), - isReplyToEncryptedMessage, - account.isOpenPgpEncryptAllDrafts(), - account.isOpenPgpEncryptSubject(), - currentCryptoMode); - - if (openPgpProviderState != OpenPgpProviderState.OK) { - cachedCryptoStatus = composeCryptoStatus; - redrawCachedCryptoStatusIcon(); - return; - } - - final String[] recipientAddresses = composeCryptoStatus.getRecipientAddressesAsArray(); - - new AsyncTask() { - @Override - protected RecipientAutocryptStatus doInBackground(Void... voids) { - OpenPgpApi openPgpApi = openPgpApiManager.getOpenPgpApi(); - if (openPgpApi == null) { - return null; - } - return autocryptStatusInteractor.retrieveCryptoProviderRecipientStatus(openPgpApi, recipientAddresses); - } - - @Override - protected void onPostExecute(RecipientAutocryptStatus recipientAutocryptStatus) { - if (recipientAutocryptStatus != null) { - cachedCryptoStatus = composeCryptoStatus.withRecipientAutocryptStatus(recipientAutocryptStatus); - } else { - cachedCryptoStatus = composeCryptoStatus; - } - - redrawCachedCryptoStatusIcon(); - } - }.execute(); - } - - private void redrawCachedCryptoStatusIcon() { - if (cachedCryptoStatus == null) { - throw new IllegalStateException("must have cached crypto status to redraw it!"); - } - - recipientMvpView.setRecipientTokensShowCryptoEnabled(cachedCryptoStatus.isEncryptionEnabled()); - - CryptoStatusDisplayType cryptoStatusDisplayType = cachedCryptoStatus.getDisplayType(); - recipientMvpView.showCryptoStatus(cryptoStatusDisplayType); - recipientMvpView.showCryptoSpecialMode(cachedCryptoStatus.getSpecialModeDisplayType()); - } - - @Nullable - public ComposeCryptoStatus getCurrentCachedCryptoStatus() { - return cachedCryptoStatus; - } - - public boolean isForceTextMessageFormat() { - return cryptoEnablePgpInline; - } - - void onToTokenAdded() { - asyncUpdateCryptoStatus(); - } - - void onToTokenRemoved() { - asyncUpdateCryptoStatus(); - } - - void onToTokenChanged() { - asyncUpdateCryptoStatus(); - } - - void onCcTokenAdded() { - asyncUpdateCryptoStatus(); - } - - void onCcTokenRemoved() { - asyncUpdateCryptoStatus(); - } - - void onCcTokenChanged() { - asyncUpdateCryptoStatus(); - } - - void onBccTokenAdded() { - asyncUpdateCryptoStatus(); - } - - void onBccTokenRemoved() { - asyncUpdateCryptoStatus(); - } - - void onBccTokenChanged() { - asyncUpdateCryptoStatus(); - } - - public void onCryptoModeChanged(CryptoMode cryptoMode) { - currentCryptoMode = cryptoMode; - asyncUpdateCryptoStatus(); - } - - public void onCryptoPgpInlineChanged(boolean enablePgpInline) { - cryptoEnablePgpInline = enablePgpInline; - asyncUpdateCryptoStatus(); - } - - private void addRecipientsFromAddresses(final RecipientType recipientType, final Address... addresses) { - new RecipientLoader(context, account.getOpenPgpProvider(), addresses) { - @Override - public void deliverResult(List result) { - Recipient[] recipientArray = result.toArray(new Recipient[result.size()]); - recipientMvpView.addRecipients(recipientType, recipientArray); - - stopLoading(); - abandon(); - } - }.startLoading(); - } - - private void addRecipientFromContactUri(final RecipientType recipientType, final Uri uri) { - new RecipientLoader(context, account.getOpenPgpProvider(), uri, false) { - @Override - public void deliverResult(List result) { - // TODO handle multiple available mail addresses for a contact? - if (result.isEmpty()) { - recipientMvpView.showErrorContactNoAddress(); - return; - } - - Recipient recipient = result.get(0); - recipientMvpView.addRecipients(recipientType, recipient); - - stopLoading(); - abandon(); - } - }.startLoading(); - } - - void onToFocused() { - lastFocusedType = RecipientType.TO; - } - - void onCcFocused() { - lastFocusedType = RecipientType.CC; - } - - void onBccFocused() { - lastFocusedType = RecipientType.BCC; - } - - public void onMenuAddFromContacts() { - int requestCode = recipientTypeToRequestCode(lastFocusedType); - recipientMvpView.showContactPicker(requestCode); - } - - public void onActivityResult(int requestCode, int resultCode, Intent data) { - switch (requestCode) { - case CONTACT_PICKER_TO: - case CONTACT_PICKER_CC: - case CONTACT_PICKER_BCC: - if (resultCode != Activity.RESULT_OK || data == null) { - return; - } - RecipientType recipientType = recipientTypeFromRequestCode(requestCode); - addRecipientFromContactUri(recipientType, data.getData()); - break; - case OPENPGP_USER_INTERACTION: - openPgpApiManager.onUserInteractionResult(); - break; - case REQUEST_CODE_AUTOCRYPT: - asyncUpdateCryptoStatus(); - break; - } - } - - private static int recipientTypeToRequestCode(RecipientType type) { - switch (type) { - case TO: { - return CONTACT_PICKER_TO; - } - case CC: { - return CONTACT_PICKER_CC; - } - case BCC: { - return CONTACT_PICKER_BCC; - } - } - - throw new AssertionError("Unhandled case: " + type); - } - - private static RecipientType recipientTypeFromRequestCode(int type) { - switch (type) { - case CONTACT_PICKER_TO: { - return RecipientType.TO; - } - case CONTACT_PICKER_CC: { - return RecipientType.CC; - } - case CONTACT_PICKER_BCC: { - return RecipientType.BCC; - } - } - - throw new AssertionError("Unhandled case: " + type); - } - - public void onNonRecipientFieldFocused() { - if (!account.isAlwaysShowCcBcc()) { - hideEmptyExtendedRecipientFields(); - } - } - - void onClickCryptoStatus() { - switch (openPgpApiManager.getOpenPgpProviderState()) { - case UNCONFIGURED: - Timber.e("click on crypto status while unconfigured - this should not really happen?!"); - return; - case OK: - toggleEncryptionState(false); - return; - case UI_REQUIRED: - // TODO show openpgp settings - PendingIntent pendingIntent = openPgpApiManager.getUserInteractionPendingIntent(); - recipientMvpView.launchUserInteractionPendingIntent(pendingIntent, OPENPGP_USER_INTERACTION); - break; - case UNINITIALIZED: - case ERROR: - openPgpApiManager.refreshConnection(); - } - } - - private void toggleEncryptionState(boolean showGotIt) { - ComposeCryptoStatus currentCryptoStatus = getCurrentCachedCryptoStatus(); - if (currentCryptoStatus == null) { - Timber.e("click on crypto status while crypto status not available - should not really happen?!"); - return; - } - - if (currentCryptoStatus.isEncryptionEnabled() && !currentCryptoStatus.allRecipientsCanEncrypt()) { - recipientMvpView.showOpenPgpEnabledErrorDialog(false); - return; - } - - if (currentCryptoMode == CryptoMode.SIGN_ONLY) { - recipientMvpView.showErrorIsSignOnly(); - return; - } - - boolean isEncryptOnNoChoice = currentCryptoStatus.canEncryptAndIsMutualDefault() || - currentCryptoStatus.isReplyToEncrypted(); - if (currentCryptoMode == CryptoMode.NO_CHOICE) { - if (currentCryptoStatus.hasAutocryptPendingIntent()) { - recipientMvpView.launchUserInteractionPendingIntent( - currentCryptoStatus.getAutocryptPendingIntent(), REQUEST_CODE_AUTOCRYPT); - } else if (isEncryptOnNoChoice) { - // TODO warning dialog if we override, especially from reply! - onCryptoModeChanged(CryptoMode.CHOICE_DISABLED); - } else { - onCryptoModeChanged(CryptoMode.CHOICE_ENABLED); - if (showGotIt) { - recipientMvpView.showOpenPgpEncryptExplanationDialog(); - } - } - } else if (currentCryptoMode == CryptoMode.CHOICE_DISABLED && !isEncryptOnNoChoice) { - onCryptoModeChanged(CryptoMode.CHOICE_ENABLED); - } else { - onCryptoModeChanged(CryptoMode.NO_CHOICE); - } - } - - /** - * Does the device actually have a Contacts application suitable for - * picking a contact. As hard as it is to believe, some vendors ship - * without it. - * - * @return True, if the device supports picking contacts. False, otherwise. - */ - private boolean hasContactPicker() { - if (hasContactPicker == null) { - Contacts contacts = Contacts.getInstance(context); - - PackageManager packageManager = context.getPackageManager(); - List resolveInfoList = packageManager.queryIntentActivities(contacts.contactPickerIntent(), 0); - hasContactPicker = !resolveInfoList.isEmpty(); - } - - return hasContactPicker; - } - - private boolean hasContactPermission() { - return ContextCompat.checkSelfPermission(context, - Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED; - } - - public void showPgpSendError(SendErrorState sendErrorState) { - switch (sendErrorState) { - case ENABLED_ERROR: - recipientMvpView.showOpenPgpEnabledErrorDialog(false); - break; - case PROVIDER_ERROR: - recipientMvpView.showErrorOpenPgpConnection(); - break; - case KEY_CONFIG_ERROR: - recipientMvpView.showErrorNoKeyConfigured(); - break; - default: - throw new AssertionError("not all error states handled, this is a bug!"); - } - } - - void showPgpAttachError(AttachErrorState attachErrorState) { - switch (attachErrorState) { - case IS_INLINE: - recipientMvpView.showErrorInlineAttach(); - break; - default: - throw new AssertionError("not all error states handled, this is a bug!"); - } - } - - public void builderSetProperties(MessageBuilder messageBuilder) { - if (messageBuilder instanceof PgpMessageBuilder) { - throw new IllegalArgumentException("PpgMessageBuilder must be called with ComposeCryptoStatus argument!"); - } - - messageBuilder.setTo(getToAddresses()); - messageBuilder.setCc(getCcAddresses()); - messageBuilder.setBcc(getBccAddresses()); - } - - public void builderSetProperties(PgpMessageBuilder pgpMessageBuilder, ComposeCryptoStatus cryptoStatus) { - pgpMessageBuilder.setTo(getToAddresses()); - pgpMessageBuilder.setCc(getCcAddresses()); - pgpMessageBuilder.setBcc(getBccAddresses()); - - pgpMessageBuilder.setOpenPgpApi(openPgpApiManager.getOpenPgpApi()); - pgpMessageBuilder.setCryptoStatus(cryptoStatus); - } - - public void onMenuSetPgpInline(boolean enablePgpInline) { - onCryptoPgpInlineChanged(enablePgpInline); - if (enablePgpInline) { - boolean shouldShowPgpInlineDialog = checkAndIncrementPgpInlineDialogCounter(); - if (shouldShowPgpInlineDialog) { - recipientMvpView.showOpenPgpInlineDialog(true); - } - } - } - - public void onMenuSetSignOnly(boolean enableSignOnly) { - if (enableSignOnly) { - onCryptoModeChanged(CryptoMode.SIGN_ONLY); - boolean shouldShowPgpSignOnlyDialog = checkAndIncrementPgpSignOnlyDialogCounter(); - if (shouldShowPgpSignOnlyDialog) { - recipientMvpView.showOpenPgpSignOnlyDialog(true); - } - } else { - onCryptoModeChanged(CryptoMode.NO_CHOICE); - } - } - - public void onMenuToggleEncryption() { - toggleEncryptionState(true); - } - - public void onCryptoPgpClickDisable() { - onCryptoModeChanged(CryptoMode.CHOICE_DISABLED); - } - - public void onCryptoPgpSignOnlyDisabled() { - onCryptoPgpInlineChanged(false); - onCryptoModeChanged(CryptoMode.NO_CHOICE); - } - - private boolean checkAndIncrementPgpInlineDialogCounter() { - int pgpInlineDialogCounter = K9.getPgpInlineDialogCounter(); - if (pgpInlineDialogCounter < PGP_DIALOG_DISPLAY_THRESHOLD) { - K9.setPgpInlineDialogCounter(pgpInlineDialogCounter + 1); - K9.saveSettingsAsync(); - return true; - } - return false; - } - - private boolean checkAndIncrementPgpSignOnlyDialogCounter() { - int pgpSignOnlyDialogCounter = K9.getPgpSignOnlyDialogCounter(); - if (pgpSignOnlyDialogCounter < PGP_DIALOG_DISPLAY_THRESHOLD) { - K9.setPgpSignOnlyDialogCounter(pgpSignOnlyDialogCounter + 1); - K9.saveSettingsAsync(); - return true; - } - return false; - } - - void onClickCryptoSpecialModeIndicator() { - if (currentCryptoMode == CryptoMode.SIGN_ONLY) { - recipientMvpView.showOpenPgpSignOnlyDialog(false); - } else if (cryptoEnablePgpInline) { - recipientMvpView.showOpenPgpInlineDialog(false); - } else { - throw new IllegalStateException("This icon should not be clickable while no special mode is active!"); - } - } - - public boolean shouldSaveRemotely() { - // TODO more appropriate logic? - return cachedCryptoStatus == null || !cachedCryptoStatus.isEncryptionEnabled(); - } - - private final OpenPgpApiManagerCallback openPgpCallback = new OpenPgpApiManagerCallback() { - @Override - public void onOpenPgpProviderStatusChanged() { - if (openPgpApiManager.getOpenPgpProviderState() == OpenPgpProviderState.UI_REQUIRED) { - recipientMvpView.showErrorOpenPgpUserInteractionRequired(); - } - - asyncUpdateCryptoStatus(); - } - - @Override - public void onOpenPgpProviderError(OpenPgpProviderError error) { - switch (error) { - case ConnectionLost: - openPgpApiManager.refreshConnection(); - break; - case VersionIncompatible: - recipientMvpView.showErrorOpenPgpIncompatible(); - break; - case ConnectionFailed: - default: - recipientMvpView.showErrorOpenPgpConnection(); - break; - } - } - }; - - public enum CryptoMode { - SIGN_ONLY, - NO_CHOICE, - CHOICE_DISABLED, - CHOICE_ENABLED, - } -} 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 new file mode 100644 index 0000000000000000000000000000000000000000..9b0e6d2ed35582adb08a7e13c8c53adeb47665c3 --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientPresenter.kt @@ -0,0 +1,770 @@ +package com.fsck.k9.activity.compose + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.AsyncTask +import android.os.Bundle +import android.view.Menu +import androidx.core.content.ContextCompat +import androidx.loader.app.LoaderManager +import com.fsck.k9.Account +import com.fsck.k9.Identity +import com.fsck.k9.K9 +import com.fsck.k9.activity.compose.ComposeCryptoStatus.AttachErrorState +import com.fsck.k9.activity.compose.ComposeCryptoStatus.SendErrorState +import com.fsck.k9.autocrypt.AutocryptDraftStateHeader +import com.fsck.k9.autocrypt.AutocryptDraftStateHeaderParser +import com.fsck.k9.helper.Contacts +import com.fsck.k9.helper.MailTo +import com.fsck.k9.helper.ReplyToParser +import com.fsck.k9.mail.Address +import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.Message +import com.fsck.k9.mail.Message.RecipientType +import com.fsck.k9.message.AutocryptStatusInteractor +import com.fsck.k9.message.AutocryptStatusInteractor.RecipientAutocryptStatus +import com.fsck.k9.message.ComposePgpEnableByDefaultDecider +import com.fsck.k9.message.ComposePgpInlineDecider +import com.fsck.k9.message.MessageBuilder +import com.fsck.k9.message.PgpMessageBuilder +import com.fsck.k9.ui.R +import com.fsck.k9.view.RecipientSelectView.Recipient +import org.openintents.openpgp.OpenPgpApiManager +import org.openintents.openpgp.OpenPgpApiManager.OpenPgpApiManagerCallback +import org.openintents.openpgp.OpenPgpApiManager.OpenPgpProviderError +import org.openintents.openpgp.OpenPgpApiManager.OpenPgpProviderState +import timber.log.Timber + +private const val STATE_KEY_CC_SHOWN = "state:ccShown" +private const val STATE_KEY_BCC_SHOWN = "state:bccShown" +private const val STATE_KEY_LAST_FOCUSED_TYPE = "state:lastFocusedType" +private const val STATE_KEY_CURRENT_CRYPTO_MODE = "state:currentCryptoMode" +private const val STATE_KEY_CRYPTO_ENABLE_PGP_INLINE = "state:cryptoEnablePgpInline" + +private const val CONTACT_PICKER_TO = 1 +private const val CONTACT_PICKER_CC = 2 +private const val CONTACT_PICKER_BCC = 3 +private const val OPENPGP_USER_INTERACTION = 4 +private const val REQUEST_CODE_AUTOCRYPT = 5 + +private const val PGP_DIALOG_DISPLAY_THRESHOLD = 2 + +class RecipientPresenter( + private val context: Context, + loaderManager: LoaderManager, + private val openPgpApiManager: OpenPgpApiManager, + private val recipientMvpView: RecipientMvpView, + account: Account, + private val composePgpInlineDecider: ComposePgpInlineDecider, + private val composePgpEnableByDefaultDecider: ComposePgpEnableByDefaultDecider, + private val autocryptStatusInteractor: AutocryptStatusInteractor, + private val replyToParser: ReplyToParser, + private val draftStateHeaderParser: AutocryptDraftStateHeaderParser +) { + private lateinit var account: Account + private var alwaysBccAddresses: Array
? = null + private var hasContactPicker: Boolean? = null + private var isReplyToEncryptedMessage = false + + private var lastFocusedType = RecipientType.TO + private var currentCryptoMode = CryptoMode.NO_CHOICE + + var isForceTextMessageFormat = false + private set + + var currentCachedCryptoStatus: ComposeCryptoStatus? = null + private set + + val toAddresses: List
+ get() = recipientMvpView.toAddresses + + val ccAddresses: List
+ get() = recipientMvpView.ccAddresses + + val bccAddresses: List
+ get() = recipientMvpView.bccAddresses + + private val allRecipients: List + get() = with(recipientMvpView) { toRecipients + ccRecipients + bccRecipients } + + init { + recipientMvpView.setPresenter(this) + recipientMvpView.setLoaderManager(loaderManager) + + onSwitchAccount(account) + } + + fun checkRecipientsOkForSending(): Boolean { + recipientMvpView.recipientToTryPerformCompletion() + recipientMvpView.recipientCcTryPerformCompletion() + recipientMvpView.recipientBccTryPerformCompletion() + + if (recipientMvpView.recipientToHasUncompletedText()) { + recipientMvpView.showToUncompletedError() + return true + } + + if (recipientMvpView.recipientCcHasUncompletedText()) { + recipientMvpView.showCcUncompletedError() + return true + } + + if (recipientMvpView.recipientBccHasUncompletedText()) { + recipientMvpView.showBccUncompletedError() + return true + } + + if (toAddresses.isEmpty() && ccAddresses.isEmpty() && bccAddresses.isEmpty()) { + recipientMvpView.showNoRecipientsError() + return true + } + + return false + } + + fun initFromReplyToMessage(message: Message?, isReplyAll: Boolean) { + val replyToAddresses = if (isReplyAll) { + replyToParser.getRecipientsToReplyAllTo(message, account) + } else { + replyToParser.getRecipientsToReplyTo(message, account) + } + + addToAddresses(*replyToAddresses.to) + addCcAddresses(*replyToAddresses.cc) + + val shouldSendAsPgpInline = composePgpInlineDecider.shouldReplyInline(message) + if (shouldSendAsPgpInline) { + isForceTextMessageFormat = true + } + + isReplyToEncryptedMessage = composePgpEnableByDefaultDecider.shouldEncryptByDefault(message) + } + + fun initFromTrustIdAction(trustId: String?) { + addToAddresses(*Address.parse(trustId)) + currentCryptoMode = CryptoMode.CHOICE_ENABLED + } + + fun initFromMailto(mailTo: MailTo) { + addToAddresses(*mailTo.to) + addCcAddresses(*mailTo.cc) + addBccAddresses(*mailTo.bcc) + } + + fun initFromSendOrViewIntent(intent: Intent) { + val toAddresses = intent.getStringArrayExtra(Intent.EXTRA_EMAIL)?.toAddressArray() + val ccAddresses = intent.getStringArrayExtra(Intent.EXTRA_CC)?.toAddressArray() + val bccAddresses = intent.getStringArrayExtra(Intent.EXTRA_BCC)?.toAddressArray() + + if (toAddresses != null) { + addToAddresses(*toAddresses) + } + + if (ccAddresses != null) { + addCcAddresses(*ccAddresses) + } + + if (bccAddresses != null) { + addBccAddresses(*bccAddresses) + } + } + + fun onRestoreInstanceState(savedInstanceState: Bundle) { + recipientMvpView.setCcVisibility(savedInstanceState.getBoolean(STATE_KEY_CC_SHOWN)) + recipientMvpView.setBccVisibility(savedInstanceState.getBoolean(STATE_KEY_BCC_SHOWN)) + lastFocusedType = RecipientType.valueOf(savedInstanceState.getString(STATE_KEY_LAST_FOCUSED_TYPE)!!) + currentCryptoMode = CryptoMode.valueOf(savedInstanceState.getString(STATE_KEY_CURRENT_CRYPTO_MODE)!!) + isForceTextMessageFormat = savedInstanceState.getBoolean(STATE_KEY_CRYPTO_ENABLE_PGP_INLINE) + + updateRecipientExpanderVisibility() + } + + fun onSaveInstanceState(outState: Bundle) { + outState.putBoolean(STATE_KEY_CC_SHOWN, recipientMvpView.isCcVisible) + outState.putBoolean(STATE_KEY_BCC_SHOWN, recipientMvpView.isBccVisible) + outState.putString(STATE_KEY_LAST_FOCUSED_TYPE, lastFocusedType.toString()) + outState.putString(STATE_KEY_CURRENT_CRYPTO_MODE, currentCryptoMode.toString()) + outState.putBoolean(STATE_KEY_CRYPTO_ENABLE_PGP_INLINE, isForceTextMessageFormat) + } + + fun initFromDraftMessage(message: Message) { + initRecipientsFromDraftMessage(message) + + val draftStateHeader = message.getHeader(AutocryptDraftStateHeader.AUTOCRYPT_DRAFT_STATE_HEADER) + if (draftStateHeader.size == 1) { + initEncryptionStateFromDraftStateHeader(draftStateHeader.first()) + } else { + initPgpInlineFromDraftMessage(message) + } + } + + private fun initEncryptionStateFromDraftStateHeader(headerValue: String) { + val autocryptDraftStateHeader = draftStateHeaderParser.parseAutocryptDraftStateHeader(headerValue) + if (autocryptDraftStateHeader != null) { + initEncryptionStateFromDraftStateHeader(autocryptDraftStateHeader) + } + } + + private fun initRecipientsFromDraftMessage(message: Message) { + addToAddresses(*message.getRecipients(RecipientType.TO)) + addCcAddresses(*message.getRecipients(RecipientType.CC)) + addBccAddresses(*message.getRecipients(RecipientType.BCC)) + } + + private fun initEncryptionStateFromDraftStateHeader(draftState: AutocryptDraftStateHeader) { + isForceTextMessageFormat = draftState.isPgpInline + isReplyToEncryptedMessage = draftState.isReply + + if (!draftState.isByChoice) { + // TODO if it's not by choice, we're going with our defaults. should we do something here if those differ? + return + } + + currentCryptoMode = when { + draftState.isSignOnly -> CryptoMode.SIGN_ONLY + draftState.isEncrypt -> CryptoMode.CHOICE_ENABLED + else -> CryptoMode.CHOICE_DISABLED + } + } + + private fun initPgpInlineFromDraftMessage(message: Message) { + isForceTextMessageFormat = message.isSet(Flag.X_DRAFT_OPENPGP_INLINE) + } + + private fun addToAddresses(vararg toAddresses: Address) { + addRecipientsFromAddresses(RecipientType.TO, *toAddresses) + } + + private fun addCcAddresses(vararg ccAddresses: Address) { + if (ccAddresses.isNotEmpty()) { + addRecipientsFromAddresses(RecipientType.CC, *ccAddresses) + recipientMvpView.setCcVisibility(true) + updateRecipientExpanderVisibility() + } + } + + private fun addBccAddresses(vararg bccRecipients: Address) { + if (bccRecipients.isNotEmpty()) { + addRecipientsFromAddresses(RecipientType.BCC, *bccRecipients) + recipientMvpView.setBccVisibility(true) + updateRecipientExpanderVisibility() + } + } + + private fun addAlwaysBcc() { + val alwaysBccAddresses = Address.parse(account.alwaysBcc) + this.alwaysBccAddresses = alwaysBccAddresses + if (alwaysBccAddresses.isEmpty()) return + + object : RecipientLoader(context, account.openPgpProvider, *alwaysBccAddresses) { + override fun deliverResult(result: List?) { + val recipientArray = result!!.toTypedArray() + recipientMvpView.silentlyAddBccAddresses(*recipientArray) + + stopLoading() + abandon() + } + }.startLoading() + } + + private fun removeAlwaysBcc() { + alwaysBccAddresses?.let { alwaysBccAddresses -> + recipientMvpView.silentlyRemoveBccAddresses(alwaysBccAddresses) + } + } + + fun onPrepareOptionsMenu(menu: Menu) { + val currentCryptoStatus = currentCachedCryptoStatus + + if (currentCryptoStatus != null && currentCryptoStatus.isProviderStateOk()) { + val isEncrypting = currentCryptoStatus.isEncryptionEnabled + menu.findItem(R.id.openpgp_encrypt_enable).isVisible = !isEncrypting + menu.findItem(R.id.openpgp_encrypt_disable).isVisible = isEncrypting + + val showSignOnly = !account.isOpenPgpHideSignOnly + val isSignOnly = currentCryptoStatus.isSignOnly + menu.findItem(R.id.openpgp_sign_only).isVisible = showSignOnly && !isSignOnly + menu.findItem(R.id.openpgp_sign_only_disable).isVisible = showSignOnly && isSignOnly + + val pgpInlineModeEnabled = currentCryptoStatus.isPgpInlineModeEnabled + val showPgpInlineEnable = (isEncrypting || isSignOnly) && !pgpInlineModeEnabled + menu.findItem(R.id.openpgp_inline_enable).isVisible = showPgpInlineEnable + menu.findItem(R.id.openpgp_inline_disable).isVisible = pgpInlineModeEnabled + } else { + menu.findItem(R.id.openpgp_inline_enable).isVisible = false + menu.findItem(R.id.openpgp_inline_disable).isVisible = false + menu.findItem(R.id.openpgp_encrypt_enable).isVisible = false + menu.findItem(R.id.openpgp_encrypt_disable).isVisible = false + menu.findItem(R.id.openpgp_sign_only).isVisible = false + menu.findItem(R.id.openpgp_sign_only_disable).isVisible = false + } + + menu.findItem(R.id.add_from_contacts).isVisible = hasContactPermission() && hasContactPicker() + } + + fun onSwitchAccount(account: Account) { + this.account = account + + if (account.isAlwaysShowCcBcc) { + recipientMvpView.setCcVisibility(true) + recipientMvpView.setBccVisibility(true) + updateRecipientExpanderVisibility() + } + + removeAlwaysBcc() + addAlwaysBcc() + + val openPgpProvider = account.openPgpProvider + recipientMvpView.setCryptoProvider(openPgpProvider) + openPgpApiManager.setOpenPgpProvider(openPgpProvider, openPgpCallback) + } + + fun onSwitchIdentity(identity: Identity) { + // TODO decide what actually to do on identity switch? + asyncUpdateCryptoStatus() + } + + fun onClickToLabel() { + recipientMvpView.requestFocusOnToField() + } + + fun onClickCcLabel() { + recipientMvpView.requestFocusOnCcField() + } + + fun onClickBccLabel() { + recipientMvpView.requestFocusOnBccField() + } + + fun onClickRecipientExpander() { + recipientMvpView.setCcVisibility(true) + recipientMvpView.setBccVisibility(true) + updateRecipientExpanderVisibility() + } + + private fun hideEmptyExtendedRecipientFields() { + if (recipientMvpView.ccAddresses.isEmpty()) { + recipientMvpView.setCcVisibility(false) + if (lastFocusedType == RecipientType.CC) { + lastFocusedType = RecipientType.TO + } + } + + if (recipientMvpView.bccAddresses.isEmpty()) { + recipientMvpView.setBccVisibility(false) + if (lastFocusedType == RecipientType.BCC) { + lastFocusedType = RecipientType.TO + } + } + + updateRecipientExpanderVisibility() + } + + private fun updateRecipientExpanderVisibility() { + val notBothAreVisible = !(recipientMvpView.isCcVisible && recipientMvpView.isBccVisible) + recipientMvpView.setRecipientExpanderVisibility(notBothAreVisible) + } + + fun asyncUpdateCryptoStatus() { + currentCachedCryptoStatus = null + + val openPgpProviderState = openPgpApiManager.openPgpProviderState + var accountCryptoKey: Long? = account.openPgpKey + if (accountCryptoKey == Account.NO_OPENPGP_KEY) { + accountCryptoKey = null + } + + val composeCryptoStatus = ComposeCryptoStatus( + openPgpProviderState = openPgpProviderState, + openPgpKeyId = accountCryptoKey, + recipientAddresses = allRecipients, + isPgpInlineModeEnabled = isForceTextMessageFormat, + isSenderPreferEncryptMutual = account.autocryptPreferEncryptMutual, + isReplyToEncrypted = isReplyToEncryptedMessage, + isEncryptAllDrafts = account.isOpenPgpEncryptAllDrafts, + isEncryptSubject = account.isOpenPgpEncryptSubject, + cryptoMode = currentCryptoMode + ) + + if (openPgpProviderState != OpenPgpProviderState.OK) { + currentCachedCryptoStatus = composeCryptoStatus + redrawCachedCryptoStatusIcon() + return + } + + val recipientAddresses = composeCryptoStatus.recipientAddressesAsArray + object : AsyncTask() { + override fun doInBackground(vararg params: Void?): RecipientAutocryptStatus? { + val openPgpApi = openPgpApiManager.openPgpApi ?: return null + return autocryptStatusInteractor.retrieveCryptoProviderRecipientStatus(openPgpApi, recipientAddresses) + } + + override fun onPostExecute(recipientAutocryptStatus: RecipientAutocryptStatus?) { + currentCachedCryptoStatus = if (recipientAutocryptStatus != null) { + composeCryptoStatus.withRecipientAutocryptStatus(recipientAutocryptStatus) + } else { + composeCryptoStatus + } + + redrawCachedCryptoStatusIcon() + } + }.execute() + } + + 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) + } + + fun onToTokenAdded() { + asyncUpdateCryptoStatus() + } + + fun onToTokenRemoved() { + asyncUpdateCryptoStatus() + } + + fun onToTokenChanged() { + asyncUpdateCryptoStatus() + } + + fun onCcTokenAdded() { + asyncUpdateCryptoStatus() + } + + fun onCcTokenRemoved() { + asyncUpdateCryptoStatus() + } + + fun onCcTokenChanged() { + asyncUpdateCryptoStatus() + } + + fun onBccTokenAdded() { + asyncUpdateCryptoStatus() + } + + fun onBccTokenRemoved() { + asyncUpdateCryptoStatus() + } + + fun onBccTokenChanged() { + asyncUpdateCryptoStatus() + } + + fun onCryptoModeChanged(cryptoMode: CryptoMode) { + currentCryptoMode = cryptoMode + asyncUpdateCryptoStatus() + } + + fun onCryptoPgpInlineChanged(enablePgpInline: Boolean) { + isForceTextMessageFormat = enablePgpInline + asyncUpdateCryptoStatus() + } + + private fun addRecipientsFromAddresses(recipientType: RecipientType, vararg addresses: Address) { + object : RecipientLoader(context, account.openPgpProvider, *addresses) { + override fun deliverResult(result: List?) { + val recipientArray = result!!.toTypedArray() + recipientMvpView.addRecipients(recipientType, *recipientArray) + + stopLoading() + abandon() + } + }.startLoading() + } + + 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? + if (result!!.isEmpty()) { + recipientMvpView.showErrorContactNoAddress() + return + } + + val recipient = result[0] + recipientMvpView.addRecipients(recipientType, recipient) + + stopLoading() + abandon() + } + }.startLoading() + } + + fun onToFocused() { + lastFocusedType = RecipientType.TO + } + + fun onCcFocused() { + lastFocusedType = RecipientType.CC + } + + fun onBccFocused() { + lastFocusedType = RecipientType.BCC + } + + fun onMenuAddFromContacts() { + val requestCode = lastFocusedType.toRequestCode() + recipientMvpView.showContactPicker(requestCode) + } + + fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + 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) + } + OPENPGP_USER_INTERACTION -> { + openPgpApiManager.onUserInteractionResult() + } + REQUEST_CODE_AUTOCRYPT -> { + asyncUpdateCryptoStatus() + } + } + } + + fun onNonRecipientFieldFocused() { + if (!account.isAlwaysShowCcBcc) { + hideEmptyExtendedRecipientFields() + } + } + + fun onClickCryptoStatus() { + when (openPgpApiManager.openPgpProviderState) { + OpenPgpProviderState.UNCONFIGURED -> { + Timber.e("click on crypto status while unconfigured - this should not really happen?!") + } + OpenPgpProviderState.OK -> { + toggleEncryptionState(false) + } + OpenPgpProviderState.UI_REQUIRED -> { + // TODO show openpgp settings + val pendingIntent = openPgpApiManager.userInteractionPendingIntent + recipientMvpView.launchUserInteractionPendingIntent(pendingIntent, OPENPGP_USER_INTERACTION) + } + OpenPgpProviderState.UNINITIALIZED, OpenPgpProviderState.ERROR -> { + openPgpApiManager.refreshConnection() + } + } + } + + private fun toggleEncryptionState(showGotIt: Boolean) { + val currentCryptoStatus = currentCachedCryptoStatus + if (currentCryptoStatus == null) { + Timber.e("click on crypto status while crypto status not available - should not really happen?!") + return + } + + if (currentCryptoStatus.isEncryptionEnabled && !currentCryptoStatus.allRecipientsCanEncrypt()) { + recipientMvpView.showOpenPgpEnabledErrorDialog(false) + return + } + + if (currentCryptoMode == CryptoMode.SIGN_ONLY) { + recipientMvpView.showErrorIsSignOnly() + return + } + + val isEncryptOnNoChoice = currentCryptoStatus.canEncryptAndIsMutualDefault() || + currentCryptoStatus.isReplyToEncrypted + + if (currentCryptoMode == CryptoMode.NO_CHOICE) { + if (currentCryptoStatus.hasAutocryptPendingIntent()) { + recipientMvpView.launchUserInteractionPendingIntent( + currentCryptoStatus.autocryptPendingIntent, REQUEST_CODE_AUTOCRYPT + ) + } else if (isEncryptOnNoChoice) { + // TODO warning dialog if we override, especially from reply! + onCryptoModeChanged(CryptoMode.CHOICE_DISABLED) + } else { + onCryptoModeChanged(CryptoMode.CHOICE_ENABLED) + if (showGotIt) { + recipientMvpView.showOpenPgpEncryptExplanationDialog() + } + } + } else if (currentCryptoMode == CryptoMode.CHOICE_DISABLED && !isEncryptOnNoChoice) { + onCryptoModeChanged(CryptoMode.CHOICE_ENABLED) + } else { + onCryptoModeChanged(CryptoMode.NO_CHOICE) + } + } + + /** + * Does the device actually have a Contacts application suitable for picking a contact. + * As hard as it is to believe, some vendors ship without it. + */ + private fun hasContactPicker(): Boolean { + return hasContactPicker ?: isContactPickerAvailable().also { hasContactPicker = it } + } + + private fun isContactPickerAvailable(): Boolean { + val contacts = Contacts.getInstance(context) + val resolveInfoList = context.packageManager.queryIntentActivities(contacts.contactPickerIntent(), 0) + return resolveInfoList.isNotEmpty() + } + + private fun hasContactPermission(): Boolean { + val permissionState = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) + return permissionState == PackageManager.PERMISSION_GRANTED + } + + fun showPgpSendError(sendErrorState: SendErrorState) { + when (sendErrorState) { + SendErrorState.ENABLED_ERROR -> recipientMvpView.showOpenPgpEnabledErrorDialog(false) + SendErrorState.PROVIDER_ERROR -> recipientMvpView.showErrorOpenPgpConnection() + SendErrorState.KEY_CONFIG_ERROR -> recipientMvpView.showErrorNoKeyConfigured() + else -> throw AssertionError("not all error states handled, this is a bug!") + } + } + + fun showPgpAttachError(attachErrorState: AttachErrorState) { + when (attachErrorState) { + AttachErrorState.IS_INLINE -> recipientMvpView.showErrorInlineAttach() + else -> throw AssertionError("not all error states handled, this is a bug!") + } + } + + fun builderSetProperties(messageBuilder: MessageBuilder) { + require(messageBuilder !is PgpMessageBuilder) { + "PpgMessageBuilder must be called with ComposeCryptoStatus argument!" + } + + messageBuilder.setTo(toAddresses) + messageBuilder.setCc(ccAddresses) + messageBuilder.setBcc(bccAddresses) + } + + fun builderSetProperties(pgpMessageBuilder: PgpMessageBuilder, cryptoStatus: ComposeCryptoStatus) { + pgpMessageBuilder.setTo(toAddresses) + pgpMessageBuilder.setCc(ccAddresses) + pgpMessageBuilder.setBcc(bccAddresses) + pgpMessageBuilder.setOpenPgpApi(openPgpApiManager.openPgpApi) + pgpMessageBuilder.setCryptoStatus(cryptoStatus) + } + + fun onMenuSetPgpInline(enablePgpInline: Boolean) { + onCryptoPgpInlineChanged(enablePgpInline) + + if (enablePgpInline) { + val shouldShowPgpInlineDialog = checkAndIncrementPgpInlineDialogCounter() + if (shouldShowPgpInlineDialog) { + recipientMvpView.showOpenPgpInlineDialog(true) + } + } + } + + fun onMenuSetSignOnly(enableSignOnly: Boolean) { + if (enableSignOnly) { + onCryptoModeChanged(CryptoMode.SIGN_ONLY) + + val shouldShowPgpSignOnlyDialog = checkAndIncrementPgpSignOnlyDialogCounter() + if (shouldShowPgpSignOnlyDialog) { + recipientMvpView.showOpenPgpSignOnlyDialog(true) + } + } else { + onCryptoModeChanged(CryptoMode.NO_CHOICE) + } + } + + fun onMenuToggleEncryption() { + toggleEncryptionState(true) + } + + fun onCryptoPgpClickDisable() { + onCryptoModeChanged(CryptoMode.CHOICE_DISABLED) + } + + fun onCryptoPgpSignOnlyDisabled() { + onCryptoPgpInlineChanged(false) + onCryptoModeChanged(CryptoMode.NO_CHOICE) + } + + private fun checkAndIncrementPgpInlineDialogCounter(): Boolean { + val pgpInlineDialogCounter = K9.pgpInlineDialogCounter + if (pgpInlineDialogCounter < PGP_DIALOG_DISPLAY_THRESHOLD) { + K9.pgpInlineDialogCounter = pgpInlineDialogCounter + 1 + K9.saveSettingsAsync() + return true + } + + return false + } + + private fun checkAndIncrementPgpSignOnlyDialogCounter(): Boolean { + val pgpSignOnlyDialogCounter = K9.pgpSignOnlyDialogCounter + if (pgpSignOnlyDialogCounter < PGP_DIALOG_DISPLAY_THRESHOLD) { + K9.pgpSignOnlyDialogCounter = pgpSignOnlyDialogCounter + 1 + K9.saveSettingsAsync() + return true + } + + return false + } + + fun onClickCryptoSpecialModeIndicator() { + when { + currentCryptoMode == CryptoMode.SIGN_ONLY -> { + recipientMvpView.showOpenPgpSignOnlyDialog(false) + } + isForceTextMessageFormat -> { + recipientMvpView.showOpenPgpInlineDialog(false) + } + else -> { + error("This icon should not be clickable while no special mode is active!") + } + } + } + + private val openPgpCallback = object : OpenPgpApiManagerCallback { + override fun onOpenPgpProviderStatusChanged() { + if (openPgpApiManager.openPgpProviderState == OpenPgpProviderState.UI_REQUIRED) { + recipientMvpView.showErrorOpenPgpUserInteractionRequired() + } + + asyncUpdateCryptoStatus() + } + + override fun onOpenPgpProviderError(error: OpenPgpProviderError) { + when (error) { + OpenPgpProviderError.ConnectionLost -> openPgpApiManager.refreshConnection() + OpenPgpProviderError.VersionIncompatible -> recipientMvpView.showErrorOpenPgpIncompatible() + OpenPgpProviderError.ConnectionFailed -> recipientMvpView.showErrorOpenPgpConnection() + else -> recipientMvpView.showErrorOpenPgpConnection() + } + } + } + + private fun Array.toAddressArray(): Array
{ + return flatMap { addressString -> + Address.parseUnencoded(addressString).toList() + }.toTypedArray() + } + + private fun RecipientType.toRequestCode(): Int = when (this) { + RecipientType.TO -> CONTACT_PICKER_TO + RecipientType.CC -> CONTACT_PICKER_CC + RecipientType.BCC -> CONTACT_PICKER_BCC + else -> throw AssertionError("Unhandled case: $this") + } + + private fun Int.toRecipientType(): RecipientType = when (this) { + CONTACT_PICKER_TO -> RecipientType.TO + CONTACT_PICKER_CC -> RecipientType.CC + CONTACT_PICKER_BCC -> RecipientType.BCC + else -> throw AssertionError("Unhandled case: $this") + } + + enum class CryptoMode { + SIGN_ONLY, NO_CHOICE, CHOICE_DISABLED, CHOICE_ENABLED + } +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..c8f33e0075ae1ce036a3c9d5a73eba0e866cd3a7 --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToPresenter.kt @@ -0,0 +1,68 @@ +package com.fsck.k9.activity.compose + +import android.os.Bundle +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 + + fun initFromDraftMessage(message: Message) { + message.replyTo.takeIf { it.isNotEmpty() }?.let { addresses -> + view.silentlyAddAddresses(addresses) + view.isVisible = true + } + } + + fun getAddresses(): Array
{ + return view.getAddresses() + } + + fun isNotReadyForSending(): Boolean { + return if (view.hasUncompletedText()) { + view.showError() + view.isVisible = true + true + } else { + false + } + } + + fun setIdentity(identity: Identity) { + this.identity = identity + + removeIdentityReplyTo() + addIdentityReplyTo() + } + + private fun addIdentityReplyTo() { + identityReplyTo = Address.parse(identity.replyTo)?.takeIf { it.isNotEmpty() } + identityReplyTo?.let { addresses -> + view.silentlyAddAddresses(addresses) + } + } + + private fun removeIdentityReplyTo() { + identityReplyTo?.let { addresses -> + view.silentlyRemoveAddresses(addresses) + } + } + + fun onNonRecipientFieldFocused() { + if (view.isVisible && view.getAddresses().isEmpty()) { + view.isVisible = false + } + } + + fun onSaveInstanceState(outState: Bundle) { + outState.putBoolean(STATE_KEY_REPLY_TO_SHOWN, view.isVisible) + } + + fun onRestoreInstanceState(savedInstanceState: Bundle) { + view.isVisible = savedInstanceState.getBoolean(STATE_KEY_REPLY_TO_SHOWN) + } +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..76297080e585b7aef131c9abd7cf1e393a4285b3 --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/compose/ReplyToView.kt @@ -0,0 +1,125 @@ +package com.fsck.k9.activity.compose + +import android.text.TextWatcher +import android.view.View +import android.widget.ViewAnimator +import androidx.core.view.isVisible +import com.fsck.k9.FontSizes +import com.fsck.k9.activity.MessageCompose +import com.fsck.k9.mail.Address +import com.fsck.k9.ui.R +import com.fsck.k9.view.RecipientSelectView +import com.fsck.k9.view.RecipientSelectView.Recipient + +private const val VIEW_INDEX_REPLY_TO_EXPANDER_VISIBLE = 0 +private const val VIEW_INDEX_REPLY_TO_EXPANDER_HIDDEN = 1 + +class ReplyToView(activity: MessageCompose) : View.OnClickListener { + private val replyToView: RecipientSelectView = activity.findViewById(R.id.reply_to) + private val replyToWrapper: View = activity.findViewById(R.id.reply_to_wrapper) + private val replyToDivider: View = activity.findViewById(R.id.reply_to_divider) + private val replyToExpanderContainer: ViewAnimator = activity.findViewById(R.id.reply_to_expander_container) + private val replyToExpander: View = activity.findViewById(R.id.reply_to_expander) + + private val textWatchers = mutableSetOf() + + init { + replyToExpander.setOnClickListener(this) + activity.findViewById(R.id.reply_to_label).setOnClickListener(this) + } + + var isVisible: Boolean + get() = replyToView.isVisible + set(visible) { + replyToDivider.isVisible = visible + replyToView.isVisible = visible + replyToWrapper.isVisible = visible + + if (visible && replyToExpanderContainer.displayedChild == VIEW_INDEX_REPLY_TO_EXPANDER_VISIBLE) { + replyToView.requestFocus() + replyToExpanderContainer.displayedChild = VIEW_INDEX_REPLY_TO_EXPANDER_HIDDEN + } else if (replyToExpanderContainer.displayedChild == VIEW_INDEX_REPLY_TO_EXPANDER_HIDDEN) { + replyToExpanderContainer.displayedChild = VIEW_INDEX_REPLY_TO_EXPANDER_VISIBLE + } + } + + override fun onClick(view: View) { + when (view.id) { + R.id.reply_to_expander -> isVisible = true + R.id.reply_to_label -> replyToView.requestFocus() + } + } + + fun hasUncompletedText(): Boolean { + replyToView.tryPerformCompletion() + return replyToView.hasUncompletedText() + } + + fun showError() { + replyToView.error = replyToView.context.getString(R.string.compose_error_incomplete_recipient) + } + + fun getAddresses(): Array
{ + return replyToView.addresses + } + + fun silentlyAddAddresses(addresses: Array
) { + removeAllTextChangedListeners() + + val recipients = addresses.map { Recipient(it) }.toTypedArray() + replyToView.addRecipients(*recipients) + + addAllTextChangedListeners() + } + + fun silentlyRemoveAddresses(addresses: Array
) { + val addressSet = addresses.toSet() + val recipientsToRemove = replyToView.objects.filter { it.address in addressSet } + + if (recipientsToRemove.isNotEmpty()) { + removeAllTextChangedListeners() + + for (recipient in recipientsToRemove) { + replyToView.removeObjectSync(recipient) + } + + addAllTextChangedListeners() + } + } + + fun setFontSizes(fontSizes: FontSizes, fontSize: Int) { + val tokenTextSize: Int = getTokenTextSize(fontSize) + replyToView.setTokenTextSize(tokenTextSize) + fontSizes.setViewTextSize(replyToView, fontSize) + } + + private fun getTokenTextSize(fontSize: Int): Int { + return when (fontSize) { + FontSizes.FONT_10SP -> FontSizes.FONT_10SP + FontSizes.FONT_12SP -> FontSizes.FONT_12SP + FontSizes.SMALL -> FontSizes.SMALL + FontSizes.FONT_16SP -> 15 + FontSizes.MEDIUM -> FontSizes.FONT_16SP + FontSizes.FONT_20SP -> FontSizes.MEDIUM + FontSizes.LARGE -> FontSizes.FONT_20SP + else -> FontSizes.FONT_DEFAULT + } + } + + fun addTextChangedListener(textWatcher: TextWatcher) { + textWatchers.add(textWatcher) + replyToView.addTextChangedListener(textWatcher) + } + + private fun removeAllTextChangedListeners() { + for (textWatcher in textWatchers) { + replyToView.removeTextChangedListener(textWatcher) + } + } + + private fun addAllTextChangedListeners() { + for (textWatcher in textWatchers) { + replyToView.addTextChangedListener(textWatcher) + } + } +} 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/AccountSetupIncoming.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupIncoming.java index 81d6c339cdb30956a710bf976e7b3b444b95d6f8..34a208ad62f59bc03a0c99a7538b662454b40e0f 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; @@ -43,9 +42,11 @@ import com.fsck.k9.mail.store.imap.ImapStoreSettings; import com.fsck.k9.mail.store.webdav.WebDavStoreSettings; import com.fsck.k9.preferences.Protocols; import com.fsck.k9.ui.R; +import com.fsck.k9.ui.base.extensions.TextInputLayoutHelper; import com.fsck.k9.view.ClientCertificateSpinner; import com.fsck.k9.view.ClientCertificateSpinner.OnClientCertificateChangedListener; +import java.util.Locale; import java.util.Map; import com.google.android.material.textfield.TextInputEditText; @@ -180,6 +181,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) + ); + } try { ServerSettings settings = mAccount.getIncomingServerSettings(); @@ -303,7 +313,7 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener } if (settings.port != -1) { - mPortView.setText(String.format("%d", settings.port)); + mPortView.setText(String.format(Locale.ROOT, "%d", settings.port)); } else { updatePortFromSecurityType(); } @@ -563,7 +573,7 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener clientCertificateAlias); mAccount.setOutgoingServerSettings(transportServer); - AccountSetupOutgoing.actionOutgoingSettings(this, mAccount, mMakeDefault); + AccountSetupOutgoing.actionOutgoingSettings(this, mAccount); } } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupOptions.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupOptions.java index 5e5d02bf6ef5a28157a0dc67ace4ac7ecd91c2bc..397a202f983de8597039c4801a28659aecf8a70b 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupOptions.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupOptions.java @@ -21,8 +21,6 @@ import com.fsck.k9.ui.base.K9Activity; public class AccountSetupOptions extends K9Activity implements OnClickListener { private static final String EXTRA_ACCOUNT = "account"; - private static final String EXTRA_MAKE_DEFAULT = "makeDefault"; - private Spinner mCheckFrequencyView; private Spinner mDisplayCountView; @@ -32,10 +30,9 @@ public class AccountSetupOptions extends K9Activity implements OnClickListener { private Account mAccount; - public static void actionOptions(Context context, Account account, boolean makeDefault) { + public static void actionOptions(Context context, Account account) { Intent i = new Intent(context, AccountSetupOptions.class); i.putExtra(EXTRA_ACCOUNT, account.getUuid()); - i.putExtra(EXTRA_MAKE_DEFAULT, makeDefault); context.startActivity(i); } @@ -115,10 +112,6 @@ public class AccountSetupOptions extends K9Activity implements OnClickListener { mAccount.setFolderPushMode(Account.FolderMode.NONE); Preferences.getPreferences(getApplicationContext()).saveAccount(mAccount); - if (mAccount.equals(Preferences.getPreferences(this).getDefaultAccount()) || - getIntent().getBooleanExtra(EXTRA_MAKE_DEFAULT, false)) { - Preferences.getPreferences(this).setDefaultAccount(mAccount); - } Core.setServicesEnabled(this); AccountSetupNames.actionSetNames(this, mAccount); } 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 6955d5616606f2b7d3d0ae053dc45f87b2f6c7f5..731af6f6f67bf6f9642784d81765ed623d25c1df 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 @@ -2,6 +2,8 @@ package com.fsck.k9.activity.setup; +import java.util.Locale; + import android.content.Context; import android.content.Intent; import android.os.Bundle; @@ -34,6 +36,7 @@ import com.fsck.k9.mail.AuthType; import com.fsck.k9.mail.ConnectionSecurity; import com.fsck.k9.mail.MailServerDirection; import com.fsck.k9.mail.ServerSettings; +import com.fsck.k9.ui.base.extensions.TextInputLayoutHelper; import com.fsck.k9.view.ClientCertificateSpinner; import com.fsck.k9.view.ClientCertificateSpinner.OnClientCertificateChangedListener; import com.google.android.material.textfield.TextInputEditText; @@ -44,7 +47,6 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, OnCheckedChangeListener { private static final String EXTRA_ACCOUNT = "account"; - private static final String EXTRA_MAKE_DEFAULT = "makeDefault"; private static final String STATE_SECURITY_TYPE_POSITION = "stateSecurityTypePosition"; private static final String STATE_AUTH_TYPE_POSITION = "authTypePosition"; @@ -69,12 +71,10 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, private AuthTypeAdapter mAuthTypeAdapter; private Button mNextButton; private Account mAccount; - private boolean mMakeDefault; - public static void actionOutgoingSettings(Context context, Account account, boolean makeDefault) { + public static void actionOutgoingSettings(Context context, Account account) { Intent i = new Intent(context, AccountSetupOutgoing.class); i.putExtra(EXTRA_ACCOUNT, account.getUuid()); - i.putExtra(EXTRA_MAKE_DEFAULT, makeDefault); context.startActivity(i); } @@ -137,7 +137,6 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, //FIXME: get Account object again? accountUuid = getIntent().getStringExtra(EXTRA_ACCOUNT); mAccount = Preferences.getPreferences(this).getAccount(accountUuid); - mMakeDefault = getIntent().getBooleanExtra(EXTRA_MAKE_DEFAULT, false); /* * If we're being reloaded we override the original account with the one @@ -148,6 +147,17 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, mAccount = Preferences.getPreferences(this).getAccount(accountUuid); } + 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) + ); + } + try { ServerSettings settings = mAccount.getOutgoingServerSettings(); @@ -198,7 +208,7 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, } if (settings.port != -1) { - mPortView.setText(String.format("%d", settings.port)); + mPortView.setText(String.format(Locale.ROOT, "%d", settings.port)); } else { updatePortFromSecurityType(); } @@ -468,7 +478,7 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, Preferences.getPreferences(getApplicationContext()).saveAccount(mAccount); finish(); } else { - AccountSetupOptions.actionOptions(this, mAccount, mMakeDefault); + AccountSetupOptions.actionOptions(this, mAccount); } } } 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 index 622b445446d1f44a5f95a8f089ade0a6d37cf319..a958a5e269eeecf24c1f6cd543b637f7e92f77d2 100644 --- 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 @@ -5,4 +5,4 @@ object AccountManagerConstants { const val GOOGLE_ACCOUNT_TYPE = "e.foundation.webdav.google" const val ACCOUNT_EMAIL_ADDRESS_KEY = "email_address" const val MAIL_CONTENT_AUTHORITY = "foundation.e.mail.provider.AppContentProvider" -} \ No newline at end of file +} 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 0664a5eb00d558eedd53730b99f46fc4dd2113b4..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,14 +1,13 @@ package com.fsck.k9.contacts import com.fsck.k9.mail.Address -import java.util.Locale class ContactLetterExtractor { fun extractContactLetter(address: Address): String { val displayName = address.personal ?: address.address val matchResult = EXTRACT_LETTER_PATTERN.find(displayName) - return matchResult?.value?.toUpperCase(Locale.ROOT) ?: FALLBACK_CONTACT_LETTER + return matchResult?.value?.uppercase() ?: FALLBACK_CONTACT_LETTER } companion object { 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 48f602cd4cfe7412275c07af8d8cc728ae5c9b5b..ecc400c3e985ebdee0311a04c4cd5bed1c820ee1 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() @@ -122,6 +123,9 @@ class MessageListFragment : private val isUnifiedInbox: Boolean get() = localSearch.id == SearchAccount.UNIFIED_INBOX + private val isNewMessagesView: Boolean + get() = localSearch.id == SearchAccount.NEW_MESSAGES + /** * `true` after [.onCreate] was executed. Used in [.updateTitle] to * make sure we don't access member variables before initialization is complete. @@ -252,7 +256,7 @@ class MessageListFragment : contactsPictureLoader = ContactPicture.getContactPictureLoader(), listItemListener = this, appearance = messageListAppearance, - relativeDateTimeFormatter = RelativeDateTimeFormatter(requireContext(), Clock.INSTANCE) + relativeDateTimeFormatter = RelativeDateTimeFormatter(requireContext(), clock) ) adapter.activeMessage = activeMessage @@ -325,13 +329,22 @@ class MessageListFragment : private fun setWindowTitle() { val title = when { isUnifiedInbox -> getString(R.string.integrated_inbox_title) + isNewMessagesView -> getString(R.string.new_messages_title) isManualSearch -> getString(R.string.search_results) isThreadDisplay -> threadTitle ?: "" isSingleFolderMode -> currentFolder!!.displayName else -> "" } - fragmentListener.setMessageListTitle(title) + val subtitle = account.let { account -> + if (account == null || isUnifiedInbox || preferences.accounts.size == 1) { + null + } else { + account.description + } + } + + fragmentListener.setMessageListTitle(title, subtitle) } fun progress(progress: Boolean) { @@ -404,6 +417,10 @@ class MessageListFragment : } override fun onDestroyView() { + if (isNewMessagesView && !requireActivity().isChangingConfigurations) { + messagingController.clearNewMessages(account) + } + savedListState = listView.onSaveInstanceState() super.onDestroyView() } @@ -458,10 +475,6 @@ class MessageListFragment : messagingController.addListener(activityListener) - for (account in localSearch.getAccounts(preferences)) { - messagingController.cancelNotificationsForAccount(account) - } - updateTitle() } @@ -812,7 +825,7 @@ class MessageListFragment : } computeBatchDirection() - updateActionModeTitle() + updateActionMode() computeSelectAllVisibility() } else { this.selected.clear() @@ -867,7 +880,7 @@ class MessageListFragment : } computeBatchDirection() - updateActionModeTitle() + updateActionMode() computeSelectAllVisibility() adapter.notifyDataSetChanged() @@ -881,8 +894,10 @@ class MessageListFragment : setFlag(item, Flag.FLAGGED, !item.isStarred) } - private fun updateActionModeTitle() { - actionMode!!.title = getString(R.string.actionbar_selected, selectedCount) + private fun updateActionMode() { + val actionMode = actionMode ?: error("actionMode == null") + actionMode.title = getString(R.string.actionbar_selected, selectedCount) + actionMode.invalidate() } private fun computeSelectAllVisibility() { @@ -975,7 +990,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) { @@ -991,18 +1013,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, @@ -1244,7 +1279,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) { @@ -1299,7 +1334,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() } @@ -1354,16 +1389,22 @@ class MessageListFragment : } val isOutbox: Boolean - get() { - val currentFolder = currentFolder ?: return false - return currentFolder.databaseId == account!!.outboxFolderId - } + get() = isSpecialFolder(account?.outboxFolderId) private val isInbox: Boolean - get() { - val currentFolder = currentFolder ?: return false - return currentFolder.databaseId == account!!.inboxFolderId - } + get() = isSpecialFolder(account?.inboxFolderId) + + private val isArchiveFolder: Boolean + get() = isSpecialFolder(account?.archiveFolderId) + + private val isSpamFolder: Boolean + get() = isSpecialFolder(account?.spamFolderId) + + private fun isSpecialFolder(specialFolderId: Long?): Boolean { + val folderId = specialFolderId ?: return false + val currentFolder = currentFolder ?: return false + return currentFolder.databaseId == folderId + } val isRemoteFolder: Boolean get() { @@ -1397,9 +1438,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) { @@ -1469,7 +1510,7 @@ class MessageListFragment : } recalculateSelectionCount() - updateActionModeTitle() + updateActionMode() } private fun startAndPrepareActionMode() { @@ -1487,7 +1528,7 @@ class MessageListFragment : selectedCount = adapter.messages .asSequence() .filter { it.uniqueId in selected } - .sumBy { it.threadCount.coerceAtLeast(1) } + .sumOf { it.threadCount.coerceAtLeast(1) } } fun remoteSearchFinished() { @@ -1707,15 +1748,19 @@ class MessageListFragment : // we don't support cross account actions atm if (!isSingleAccountMode) { - // show all + val accounts = accountUuidsForSelected.mapNotNull { accountUuid -> + preferences.getAccount(accountUuid) + } + menu.findItem(R.id.move).isVisible = true - menu.findItem(R.id.archive).isVisible = true - menu.findItem(R.id.spam).isVisible = true menu.findItem(R.id.copy).isVisible = true - for (accountUuid in accountUuidsForSelected) { - val account = preferences.getAccount(accountUuid) - account?.let { setContextCapabilities(it, menu) } + // Disable archive/spam options here and maybe enable below when checking account capabilities + menu.findItem(R.id.archive).isVisible = false + menu.findItem(R.id.spam).isVisible = false + + for (account in accounts) { + setContextCapabilities(account, menu) } } @@ -1754,12 +1799,30 @@ class MessageListFragment : menu.findItem(R.id.move).isVisible = false menu.findItem(R.id.copy).isVisible = false - // TODO: we could support the archive and spam operations if all selected messages - // belong to non-POP3 accounts + if (account?.hasArchiveFolder() == true) { + menu.findItem(R.id.archive).isVisible = true + } + + if (account?.hasSpamFolder() == true) { + menu.findItem(R.id.spam).isVisible = true + } + } else if (isOutbox) { + menu.findItem(R.id.mark_as_read).isVisible = false + menu.findItem(R.id.mark_as_unread).isVisible = false menu.findItem(R.id.archive).isVisible = false + menu.findItem(R.id.copy).isVisible = false + menu.findItem(R.id.flag).isVisible = false + menu.findItem(R.id.unflag).isVisible = false menu.findItem(R.id.spam).isVisible = false + menu.findItem(R.id.move).isVisible = false + + disableMarkAsRead = true + disableFlag = true + + if (account.hasDraftsFolder()) { + menu.findItem(R.id.move_to_drafts).isVisible = true + } } else { - // hide unsupported if (!messagingController.isCopyCapable(account)) { menu.findItem(R.id.copy).isVisible = false } @@ -1768,33 +1831,13 @@ class MessageListFragment : menu.findItem(R.id.move).isVisible = false menu.findItem(R.id.archive).isVisible = false menu.findItem(R.id.spam).isVisible = false - } - - val hideArchiveAction = isSingleFolderMode && currentFolder!!.databaseId == account.archiveFolderId - if (hideArchiveAction) { - menu.findItem(R.id.archive).isVisible = false - } - - val hideSpamAction = isSingleFolderMode && currentFolder!!.databaseId == account.spamFolderId - if (hideSpamAction) { - menu.findItem(R.id.spam).isVisible = false - } - - if (isOutbox) { - menu.findItem(R.id.mark_as_read).isVisible = false - menu.findItem(R.id.mark_as_unread).isVisible = false - menu.findItem(R.id.archive).isVisible = false - menu.findItem(R.id.copy).isVisible = false - menu.findItem(R.id.flag).isVisible = false - menu.findItem(R.id.unflag).isVisible = false - menu.findItem(R.id.spam).isVisible = false - menu.findItem(R.id.move).isVisible = false - - disableMarkAsRead = true - disableFlag = true + } else { + if (!account.hasArchiveFolder() || isArchiveFolder) { + menu.findItem(R.id.archive).isVisible = false + } - if (account.hasDraftsFolder()) { - menu.findItem(R.id.move_to_drafts).isVisible = true + if (!account.hasSpamFolder() || isSpamFolder) { + menu.findItem(R.id.spam).isVisible = false } } } @@ -1833,12 +1876,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 -> { @@ -1876,9 +1920,9 @@ class MessageListFragment : fun setMessageListProgress(level: Int) fun showThread(account: Account, threadRootId: Long) fun openMessage(messageReference: MessageReference) - fun setMessageListTitle(title: String) + 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 4c1b367576b16b8f6571a3f248731459864287a7..7c8262c284f1c3cd9762afee01ece61271b283eb 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 @@ -1,13 +1,16 @@ package com.fsck.k9.ui +import android.annotation.SuppressLint import android.content.Context import android.content.res.ColorStateList import android.content.res.Resources import android.graphics.drawable.Drawable import android.net.Uri import android.os.Bundle -import android.util.TypedValue +import android.view.View import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.Toast import androidx.core.view.GravityCompat import androidx.drawerlayout.widget.DrawerLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout @@ -23,7 +26,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 @@ -43,17 +48,20 @@ import com.mikepenz.materialdrawer.model.interfaces.selectedColorInt import com.mikepenz.materialdrawer.util.AbstractDrawerImageLoader import com.mikepenz.materialdrawer.util.DrawerImageLoader import com.mikepenz.materialdrawer.util.addItems -import com.mikepenz.materialdrawer.util.addStickyFooterItem import com.mikepenz.materialdrawer.util.getDrawerItem import com.mikepenz.materialdrawer.util.removeAllItems import com.mikepenz.materialdrawer.widget.AccountHeaderView import com.mikepenz.materialdrawer.widget.MaterialDrawerSliderView -import java.util.ArrayList 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 +private const val UNREAD_SYMBOL = "\u2B24" +private const val STARRED_SYMBOL = "\u2605" +private const val THIN_SPACE = "\u2009" +private const val EN_SPACE = "\u2000" + class K9Drawer(private val parent: MessageList, savedInstanceState: Bundle?) : KoinComponent { private val foldersViewModel: FoldersViewModel by parent.viewModel() private val accountsViewModel: AccountsViewModel by parent.viewModel() @@ -63,6 +71,12 @@ class K9Drawer(private val parent: MessageList, savedInstanceState: Bundle?) : K private val messagingController: MessagingController by inject() private val accountImageLoader: AccountImageLoader by inject() + private val buttonRow: LinearLayout = parent.findViewById(R.id.material_drawer_button_row) + private val buttonSettings: ImageView = parent.findViewById(R.id.drawer_button_settings) + private val buttonManageFolders: ImageView = parent.findViewById(R.id.drawer_button_manage_folders) + private val buttonRefreshAll: ImageView = parent.findViewById(R.id.drawer_button_refresh_all) + private val buttonRefreshAccount: ImageView = parent.findViewById(R.id.drawer_button_refresh_account) + private val drawer: DrawerLayout = parent.findViewById(R.id.drawerLayout) private val sliderView: MaterialDrawerSliderView = parent.findViewById(R.id.material_drawer_slider) private val headerView: AccountHeaderView = AccountHeaderView(parent).apply { @@ -81,6 +95,7 @@ class K9Drawer(private val parent: MessageList, savedInstanceState: Bundle?) : K private var folderBadgeStyle: BadgeStyle? = null private var openedAccountUuid: String? = null private var openedFolderId: Long? = null + private var latestFolderList: FolderList? = null val layout: DrawerLayout get() = drawer @@ -93,6 +108,7 @@ class K9Drawer(private val parent: MessageList, savedInstanceState: Bundle?) : K initializeImageLoader() configureAccountHeader() + configureButtonBar() drawer.addDrawerListener(parent.createDrawerListener()) sliderView.tintStatusBar = true @@ -119,14 +135,12 @@ class K9Drawer(private val parent: MessageList, savedInstanceState: Bundle?) : K } } - addFooterItems() - accountsViewModel.displayAccountsLiveData.observeNotNull(parent) { accounts -> setAccounts(accounts) } - foldersViewModel.getFolderListLiveData().observe(parent) { folders -> - setUserFolders(folders) + foldersViewModel.getFolderListLiveData().observe(parent) { folderList -> + setUserFolders(folderList) } } @@ -154,10 +168,87 @@ class K9Drawer(private val parent: MessageList, savedInstanceState: Bundle?) : K headerView.onAccountHeaderListener = { _, profile, _ -> val account = (profile as ProfileDrawerItem).tag as Account openedAccountUuid = account.uuid - parent.openRealAccount(account) - updateUserAccountsAndFolders(account) + val eventHandled = !parent.openRealAccount(account) + updateButtonBarVisibility(false) + + eventHandled + } + } + + private fun buildBadgeText(displayAccount: DisplayAccount): String? { + return buildBadgeText(displayAccount.unreadMessageCount, displayAccount.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) + } else { + buildBadgeTextWithUnreadCount(unreadCount) + } + } + + private fun buildBadgeTextWithStarredCount(unreadCount: Int, starredCount: Int): String? { + if (unreadCount == 0 && starredCount == 0) return null + + return buildString { + val hasUnreadCount = unreadCount > 0 + if (hasUnreadCount) { + append(UNREAD_SYMBOL) + append(THIN_SPACE) + append(unreadCount) + } + + if (starredCount > 0) { + if (hasUnreadCount) { + append(EN_SPACE) + } + append(STARRED_SYMBOL) + append(THIN_SPACE) + append(starredCount) + } + } + } + + private fun buildBadgeTextWithUnreadCount(unreadCount: Int): String? { + return if (unreadCount > 0) unreadCount.toString() else null + } + + private fun updateButtonBarVisibility(showsAccounts: Boolean) { + buttonManageFolders.visibility = if (showsAccounts) View.GONE else View.VISIBLE + buttonRefreshAccount.visibility = if (showsAccounts) View.GONE else View.VISIBLE + buttonRefreshAll.visibility = if (showsAccounts) View.VISIBLE else View.GONE + } + + @SuppressLint("ClickableViewAccessibility") + private fun configureButtonBar() { + headerView.onAccountHeaderSelectionViewClickListener = { view, profile -> + updateButtonBarVisibility(!headerView.selectionListShown) + false + } + updateButtonBarVisibility(headerView.selectionListShown) + + buttonRow.setOnTouchListener { _, _ -> true } // To avoid touch going through + buttonSettings.setOnClickListener { SettingsActivity.launch(parent) } + buttonManageFolders.setOnClickListener { parent.launchManageFoldersScreen() } + buttonRefreshAccount.setOnClickListener { refreshAndShowProgress(headerView.activeProfile?.tag as Account) } + buttonRefreshAll.setOnClickListener { refreshAndShowProgress(null) } + + val showContentDescription = View.OnLongClickListener { v -> + Toast.makeText(parent, v.contentDescription, Toast.LENGTH_SHORT).show() true } + buttonSettings.setOnLongClickListener(showContentDescription) + buttonManageFolders.setOnLongClickListener(showContentDescription) + buttonRefreshAccount.setOnLongClickListener(showContentDescription) + buttonRefreshAll.setOnLongClickListener(showContentDescription) } private fun setAccounts(displayAccounts: List) { @@ -166,7 +257,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() @@ -175,14 +265,14 @@ 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 selectedColorInt = drawerColors.selectedColor icon = ImageHolder(createAccountImageUri(account)) - displayAccount.unreadCount.takeIf { it > 0 }?.let { unreadCount -> - badgeText = unreadCount.toString() + buildBadgeText(displayAccount)?.let { text -> + badgeText = text badgeStyle = BadgeStyle().apply { textColorStateList = selectedTextColor } @@ -206,37 +296,8 @@ class K9Drawer(private val parent: MessageList, savedInstanceState: Bundle?) : K if (oldSelectedBackgroundColor != selectedBackgroundColor) { // Recreate list of folders with updated account color - setUserFolders(foldersViewModel.getFolderListLiveData().value) - } - } - - private fun addFooterItems() { - sliderView.addStickyFooterItem( - PrimaryDrawerItem().apply { - nameRes = R.string.folders_action - iconRes = folderIconProvider.iconFolderResId - identifier = DRAWER_ID_FOLDERS - isSelectable = false - } - ) - - sliderView.addStickyFooterItem( - PrimaryDrawerItem().apply { - nameRes = R.string.preferences_action - iconRes = getResId(R.attr.iconActionSettings) - identifier = DRAWER_ID_PREFERENCES - isSelectable = false - } - ) - } - - private fun getResId(resAttribute: Int): Int { - val typedValue = TypedValue() - val found = parent.theme.resolveAttribute(resAttribute, typedValue, true) - if (!found) { - throw AssertionError("Couldn't find resource with attribute $resAttribute") + setUserFolders(latestFolderList) } - return typedValue.resourceId } private fun getFolderDisplayName(folder: Folder): String { @@ -246,24 +307,30 @@ 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 { - val accountToRefresh = if (headerView.selectionListShown) null else account - messagingController.checkMail( - accountToRefresh, true, true, - object : SimpleMessagingListener() { - override fun checkMailFinished(context: Context?, account: Account?) { - swipeRefreshLayout.post { - swipeRefreshLayout.isRefreshing = false - } + refreshAndShowProgress(if (headerView.selectionListShown) null else account) + } + } + + private fun refreshAndShowProgress(account: Account?) { + // Account can be null to refresh all (unified inbox or account list). + if (!swipeRefreshLayout.isRefreshing) { + swipeRefreshLayout.isRefreshing = true + } + messagingController.checkMail( + account, true, true, + object : SimpleMessagingListener() { + override fun checkMailFinished(context: Context?, account: Account?) { + swipeRefreshLayout.post { + swipeRefreshLayout.isRefreshing = false } } - ) - } + } + ) } private fun initializeWithAccountColor(account: Account) { @@ -279,8 +346,6 @@ class K9Drawer(private val parent: MessageList, savedInstanceState: Bundle?) : K private fun handleItemClickListener(drawerItem: IDrawerItem<*>) { when (drawerItem.identifier) { - DRAWER_ID_PREFERENCES -> SettingsActivity.launch(parent) - DRAWER_ID_FOLDERS -> parent.launchManageFoldersScreen() DRAWER_ID_UNIFIED_INBOX -> parent.openUnifiedInbox() else -> { val folder = drawerItem.tag as Folder @@ -289,19 +354,28 @@ class K9Drawer(private val parent: MessageList, savedInstanceState: Bundle?) : K } } - private fun setUserFolders(folders: List?) { + private fun setUserFolders(folderList: FolderList?) { + this.latestFolderList = folderList clearUserFolders() var openedFolderDrawerId: Long = -1 - if (K9.isShowUnifiedInbox) { + if (folderList == null) { + return + } + + folderList.unifiedInbox?.let { unifiedInbox -> val unifiedInboxItem = PrimaryDrawerItem().apply { - iconRes = getResId(R.attr.iconUnifiedInbox) + iconRes = R.drawable.ic_inbox_multiple //getResId(R.attr.iconUnifiedInbox) identifier = DRAWER_ID_UNIFIED_INBOX nameRes = R.string.integrated_inbox_title selectedColorInt = selectedBackgroundColor textColor = selectedTextColor isSelected = unifiedInboxSelected + buildBadgeText(unifiedInbox)?.let { text -> + badgeText = text + badgeStyle = folderBadgeStyle + } } sliderView.addItems(unifiedInboxItem) @@ -312,21 +386,18 @@ class K9Drawer(private val parent: MessageList, savedInstanceState: Bundle?) : K } } - if (folders == null) { - return - } - - for (displayFolder in folders) { + 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) identifier = drawerId tag = folder nameText = getFolderDisplayName(folder) - displayFolder.unreadCount.takeIf { it > 0 }?.let { - badgeText = it.toString() + buildBadgeText(displayFolder)?.let { text -> + badgeText = text badgeStyle = folderBadgeStyle } selectedColorInt = selectedBackgroundColor @@ -449,10 +520,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 3be4635872046bbd43427792cab2b15b15b53dd3..58f7d747cf3b50f328396f2654e67d93d83e34fc 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,23 +1,37 @@ package com.fsck.k9.ui.account +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]. */ class AccountImageLoader(private val accountFallbackImageProvider: AccountFallbackImageProvider) { fun setAccountImage(imageView: ImageView, email: String, color: Int) { - Glide.with(imageView.context) - .load(AccountImage(email, color)) - .placeholder(accountFallbackImageProvider.getDrawable(color)) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .dontAnimate() - .into(imageView) + imageView.context.ifNotDestroyed { context -> + Glide.with(context) + .load(AccountImage(email, color)) + .placeholder(accountFallbackImageProvider.getDrawable(color)) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .dontAnimate() + .into(imageView) + } } fun cancel(imageView: ImageView) { - Glide.with(imageView.context).clear(imageView) + imageView.context.ifNotDestroyed { context -> + Glide.with(context).clear(imageView) + } + } + + private inline fun Context.ifNotDestroyed(block: (Context) -> Unit) { + 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/AccountsViewModel.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/AccountsViewModel.kt index ca4d6ccfdb1f38155e019792ae7daeffc20f6c5d..cb25a0dc8c6c1f54a7af9b4478418a07085c2a2d 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/AccountsViewModel.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/AccountsViewModel.kt @@ -8,9 +8,9 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.asLiveData import com.fsck.k9.Account -import com.fsck.k9.AccountsChangeListener -import com.fsck.k9.Preferences -import com.fsck.k9.controller.UnreadMessageCountProvider +import com.fsck.k9.controller.MessageCounts +import com.fsck.k9.controller.MessageCountsProvider +import com.fsck.k9.preferences.AccountManager import com.fsck.k9.provider.EmailProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -24,49 +24,37 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalCoroutinesApi::class) class AccountsViewModel( - private val preferences: Preferences, - private val unreadMessageCountProvider: UnreadMessageCountProvider, + accountManager: AccountManager, + private val messageCountsProvider: MessageCountsProvider, private val contentResolver: ContentResolver ) : ViewModel() { - private val accountsFlow: Flow> = - callbackFlow { - send(preferences.accounts) - - val accountsChangeListener = AccountsChangeListener { - launch { - send(preferences.accounts) - } - } - preferences.addOnAccountsChangeListener(accountsChangeListener) - - awaitClose { - preferences.removeOnAccountsChangeListener(accountsChangeListener) - } - }.flowOn(Dispatchers.IO) - - private val displayAccountFlow: Flow> = accountsFlow + private val displayAccountFlow: Flow> = accountManager.getAccountsFlow() .flatMapLatest { accounts -> - val unreadCountFlows: List> = accounts.map { account -> - getUnreadCountFlow(account) + val messageCountsFlows: List> = accounts.map { account -> + getMessageCountsFlow(account) } - combine(unreadCountFlows) { unreadCounts -> - unreadCounts.mapIndexed { index, unreadCount -> - DisplayAccount(account = accounts[index], unreadCount) + combine(messageCountsFlows) { messageCountsList -> + messageCountsList.mapIndexed { index, messageCounts -> + DisplayAccount( + account = accounts[index], + unreadMessageCount = messageCounts.unread, + starredMessageCount = messageCounts.starred + ) } } } - private fun getUnreadCountFlow(account: Account): Flow { + private fun getMessageCountsFlow(account: Account): Flow { return callbackFlow { val notificationUri = EmailProvider.getNotificationUri(account.uuid) - send(unreadMessageCountProvider.getUnreadMessageCount(account)) + send(messageCountsProvider.getMessageCounts(account)) val contentObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { override fun onChange(selfChange: Boolean) { launch { - send(unreadMessageCountProvider.getUnreadMessageCount(account)) + send(messageCountsProvider.getMessageCounts(account)) } } } 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 530919e86d03502c32b9772535c03027cfd3b693..ef0e30b9d52f5bb9174b749566b3f48a918aba7e 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 @@ -2,4 +2,8 @@ package com.fsck.k9.ui.account import com.fsck.k9.Account -data class DisplayAccount(val account: Account, val unreadCount: Int) +data class DisplayAccount( + val account: Account, + val unreadMessageCount: Int, + val starredMessageCount: Int +) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/KoinModule.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/KoinModule.kt index 42f07377961ef4b2ece6b20b22ba554ea433f921..f72738ab320872a115911f6b895aa9127fab3753 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/KoinModule.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/account/KoinModule.kt @@ -4,7 +4,7 @@ import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module val accountUiModule = module { - viewModel { AccountsViewModel(preferences = get(), unreadMessageCountProvider = get(), contentResolver = get()) } + viewModel { AccountsViewModel(accountManager = get(), messageCountsProvider = get(), contentResolver = get()) } factory { AccountImageLoader(accountFallbackImageProvider = get()) } factory { AccountFallbackImageProvider(context = get()) } factory { AccountImageModelLoaderFactory(contactPhotoLoader = get(), accountFallbackImageProvider = get()) } 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 74640c9fc78139d85f4eabc9e2a2e9d41b5f7b31..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 @@ -7,7 +7,6 @@ import android.os.Bundle import android.view.Menu import android.view.MenuItem import androidx.appcompat.widget.SearchView -import androidx.lifecycle.Observer import androidx.recyclerview.widget.RecyclerView import com.fsck.k9.Account import com.fsck.k9.Account.FolderMode @@ -20,7 +19,6 @@ import com.fsck.k9.ui.R import com.fsck.k9.ui.base.K9Activity import com.fsck.k9.ui.folders.FolderIconProvider import com.fsck.k9.ui.folders.FolderNameFormatter -import com.fsck.k9.ui.folders.FoldersLiveData import com.mikepenz.fastadapter.FastAdapter import com.mikepenz.fastadapter.adapters.ItemAdapter import java.util.Locale @@ -38,37 +36,43 @@ 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 private var showDisplayableOnly = false - private var foldersLiveData: FoldersLiveData? = null - - private val folderListObserver = Observer> { folders -> - updateFolderList(folders) - } 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 -> + updateFolderList(folders) + } + val savedDisplayMode = savedInstanceState?.getString(STATE_DISPLAY_MODE)?.let { FolderMode.valueOf(it) } val displayMode = savedDisplayMode ?: getInitialDisplayMode() - foldersLiveData = viewModel.getFolders(account, displayMode).apply { - observe(this@ChooseFolderActivity, folderListObserver) - } + viewModel.setDisplayMode(account, displayMode) } 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 @@ -89,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 @@ -137,7 +147,7 @@ class ChooseFolderActivity : K9Activity() { override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) scrollToFolderId?.let { folderId -> outState.putLong(STATE_SCROLL_TO_FOLDER_ID, folderId) } - outState.putString(STATE_DISPLAY_MODE, foldersLiveData?.displayMode?.name) + outState.putString(STATE_DISPLAY_MODE, viewModel.currentDisplayMode?.name) } override fun onCreateOptionsMenu(menu: Menu): Boolean { @@ -166,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) @@ -181,10 +192,7 @@ class ChooseFolderActivity : K9Activity() { } private fun setDisplayMode(displayMode: FolderMode) { - foldersLiveData?.removeObserver(folderListObserver) - foldersLiveData = viewModel.getFolders(account, displayMode).apply { - observe(this@ChooseFolderActivity, folderListObserver) - } + viewModel.setDisplayMode(account, displayMode) } private fun returnResult(folderId: Long, displayName: String) { @@ -202,9 +210,9 @@ class ChooseFolderActivity : K9Activity() { if (constraint.isNullOrEmpty()) return true val locale = Locale.getDefault() - val displayName = item.displayName.toLowerCase(locale) + val displayName = item.displayName.lowercase(locale) return constraint.splitToSequence(" ") - .map { it.toLowerCase(locale) } + .map { it.lowercase(locale) } .any { it in displayName } } @@ -219,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" @@ -234,6 +250,7 @@ class ChooseFolderActivity : K9Activity() { @JvmStatic fun buildLaunchIntent( context: Context, + action: Action, accountUuid: String, currentFolderId: Long? = null, scrollToFolderId: Long? = null, @@ -241,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 5e95c31fdce5db2b4ad7d7eab0f575133ddf496e..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 @@ -1,22 +1,39 @@ package com.fsck.k9.ui.choosefolder +import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope import com.fsck.k9.Account import com.fsck.k9.Account.FolderMode -import com.fsck.k9.ui.folders.FoldersLiveData -import com.fsck.k9.ui.folders.FoldersLiveDataFactory +import com.fsck.k9.mailstore.DisplayFolder +import com.fsck.k9.mailstore.FolderRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.launch -class ChooseFolderViewModel(private val foldersLiveDataFactory: FoldersLiveDataFactory) : ViewModel() { - private var foldersLiveData: FoldersLiveData? = null - - fun getFolders(account: Account, displayMode: FolderMode): FoldersLiveData { - val liveData = foldersLiveData - if (liveData != null && liveData.accountUuid == account.uuid && liveData.displayMode == displayMode) { - return liveData +@OptIn(ExperimentalCoroutinesApi::class) +class ChooseFolderViewModel(private val folderRepository: FolderRepository) : ViewModel() { + private val inputFlow = MutableSharedFlow(replay = 1) + private val foldersFlow = inputFlow + .flatMapLatest { (account, displayMode) -> + folderRepository.getDisplayFoldersFlow(account, displayMode) } - return foldersLiveDataFactory.create(account, displayMode).also { - foldersLiveData = it + var currentDisplayMode: FolderMode? = null + private set + + fun getFolders(): LiveData> { + return foldersFlow.asLiveData() + } + + fun setDisplayMode(account: Account, displayMode: FolderMode) { + currentDisplayMode = displayMode + viewModelScope.launch { + inputFlow.emit(DisplayMode(account, displayMode)) } } } + +private data class DisplayMode(val account: Account, val displayMode: FolderMode) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/endtoend/AutocryptKeyTransferActivity.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/endtoend/AutocryptKeyTransferActivity.kt index c98a0d353017c09120b9bf432b8ab901e652c8e5..7d6c92b6ba81f7146c920097b72024e5683025cd 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/endtoend/AutocryptKeyTransferActivity.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/endtoend/AutocryptKeyTransferActivity.kt @@ -62,8 +62,8 @@ class AutocryptKeyTransferActivity : K9Activity() { presenter.initFromIntent(accountUuid) } - override fun onOptionsItemSelected(item: MenuItem?): Boolean { - if (item?.itemId == android.R.id.home) { + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { presenter.onClickHome() return true } else { diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/endtoend/AutocryptSetupMessageLiveEvent.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/endtoend/AutocryptSetupMessageLiveEvent.kt index ffe9738e3bf72e37bf7c0b394c2a49986b90729c..4ec55169d927c6b4da625c7031092e8f9f513b7c 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/endtoend/AutocryptSetupMessageLiveEvent.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/endtoend/AutocryptSetupMessageLiveEvent.kt @@ -34,7 +34,7 @@ class AutocryptSetupMessageLiveEvent(val messageCreator: AutocryptTransferMessag val result = openPgpApi.executeApi(intent, null as InputStream?, baos) val keyData = baos.toByteArray() - val pi: PendingIntent = result.getParcelableExtra(OpenPgpApi.RESULT_INTENT) + val pi: PendingIntent = result.getParcelableExtra(OpenPgpApi.RESULT_INTENT) ?: error("Missing result intent") val setupMessage = messageCreator.createAutocryptTransferMessage(keyData, address) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/folders/FoldersLiveData.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/folders/FoldersLiveData.kt deleted file mode 100644 index c1290de8557d20bdbf492ec942fb8d3638455083..0000000000000000000000000000000000000000 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/folders/FoldersLiveData.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.fsck.k9.ui.folders - -import androidx.lifecycle.LiveData -import com.fsck.k9.Account -import com.fsck.k9.Account.FolderMode -import com.fsck.k9.AccountsChangeListener -import com.fsck.k9.Preferences -import com.fsck.k9.controller.MessagingController -import com.fsck.k9.controller.SimpleMessagingListener -import com.fsck.k9.mailstore.DisplayFolder -import com.fsck.k9.mailstore.FolderRepository -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class FoldersLiveData( - private val folderRepository: FolderRepository, - private val messagingController: MessagingController, - private val preferences: Preferences, - val accountUuid: String, - val displayMode: FolderMode? -) : LiveData>() { - - private val messagingListener = object : SimpleMessagingListener() { - override fun folderStatusChanged( - account: Account, - folderId: Long - ) { - if (account?.uuid == accountUuid) { - loadFoldersAsync() - } - } - } - - private val accountsListener = AccountsChangeListener { - loadFoldersAsync() - } - - private fun loadFoldersAsync() { - GlobalScope.launch(Dispatchers.Main) { - value = withContext(Dispatchers.IO) { folderRepository.getDisplayFolders(displayMode) } - } - } - - override fun onActive() { - super.onActive() - messagingController.addListener(messagingListener) - preferences.addOnAccountsChangeListener(accountsListener) - loadFoldersAsync() - } - - override fun onInactive() { - super.onInactive() - messagingController.removeListener(messagingListener) - preferences.removeOnAccountsChangeListener(accountsListener) - } -} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/folders/FoldersLiveDataFactory.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/folders/FoldersLiveDataFactory.kt deleted file mode 100644 index 06a68f0deec05dabd4d928a7a6d9e5cce1ad0db3..0000000000000000000000000000000000000000 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/folders/FoldersLiveDataFactory.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.fsck.k9.ui.folders - -import com.fsck.k9.Account -import com.fsck.k9.Account.FolderMode -import com.fsck.k9.Preferences -import com.fsck.k9.controller.MessagingController -import com.fsck.k9.mailstore.FolderRepositoryManager - -class FoldersLiveDataFactory( - private val folderRepositoryManager: FolderRepositoryManager, - private val messagingController: MessagingController, - private val preferences: Preferences -) { - fun create(account: Account, displayMode: FolderMode? = null): FoldersLiveData { - val folderRepository = folderRepositoryManager.getFolderRepository(account) - return FoldersLiveData(folderRepository, messagingController, preferences, account.uuid, displayMode) - } -} 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 3df964660b7cf9764942d858ca8f856c6625b374..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,42 +1,85 @@ package com.fsck.k9.ui.folders import androidx.lifecycle.LiveData -import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.ViewModel +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.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch -class FoldersViewModel(private val foldersLiveDataFactory: FoldersLiveDataFactory) : ViewModel() { - private var currentFoldersLiveData: FoldersLiveData? = null - private val foldersLiveData = MediatorLiveData>() +@OptIn(ExperimentalCoroutinesApi::class) +class FoldersViewModel( + private val folderRepository: FolderRepository, + private val messageCountsProvider: MessageCountsProvider, + backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO +) : ViewModel() { + private val inputFlow = MutableSharedFlow(replay = 1) + private val foldersFlow = inputFlow + .flatMapLatest { account -> + if (account == null) { + flowOf(0 to emptyList()) + } else { + folderRepository.getDisplayFoldersFlow(account) + .map { displayFolders -> + account.accountNumber to displayFolders + } + } + } + .map { (accountNumber, displayFolders) -> + FolderList( + unifiedInbox = createDisplayUnifiedInbox(), + accountId = accountNumber + 1, + folders = displayFolders + ) + } + .flowOn(backgroundDispatcher) - fun getFolderListLiveData(): LiveData> { - return foldersLiveData + private fun createDisplayUnifiedInbox(): DisplayUnifiedInbox? { + return getUnifiedInboxAccount()?.let { searchAccount -> + val messageCounts = messageCountsProvider.getMessageCounts(searchAccount) + DisplayUnifiedInbox(messageCounts.unread, messageCounts.starred) + } } - fun loadFolders(account: Account) { - if (currentFoldersLiveData?.accountUuid == account.uuid) return - - removeCurrentFoldersLiveData() - - val liveData = foldersLiveDataFactory.create(account) - currentFoldersLiveData = liveData - - foldersLiveData.addSource(liveData) { items -> - foldersLiveData.value = items - } + private fun getUnifiedInboxAccount(): SearchAccount? { + return if (K9.isShowUnifiedInbox) SearchAccount.createUnifiedInboxAccount() else null } - fun stopLoadingFolders() { - removeCurrentFoldersLiveData() - foldersLiveData.value = null + fun getFolderListLiveData(): LiveData { + return foldersFlow.asLiveData() } - private fun removeCurrentFoldersLiveData() { - currentFoldersLiveData?.let { - foldersLiveData.value = emptyList() - currentFoldersLiveData = null - foldersLiveData.removeSource(it) + fun loadFolders(account: Account) { + viewModelScope.launch { + // 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 +) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/folders/KoinModule.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/folders/KoinModule.kt index 51f3b427fcbe4205e9999c7f975b0e38427a0e07..083b6c82742ebdbff0194cefd25be0c52318bdf3 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/folders/KoinModule.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/folders/KoinModule.kt @@ -7,6 +7,5 @@ import org.koin.dsl.module val foldersUiModule = module { single { FolderNameFormatterFactory() } factory { (context: Context) -> FolderNameFormatter(context.resources) } - single { FoldersLiveDataFactory(get(), get(), get()) } - viewModel { FoldersViewModel(get()) } + viewModel { FoldersViewModel(folderRepository = get(), messageCountsProvider = get()) } } 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 1df1cb454038be032794452d969768b7ef809ffd..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,6 +1,7 @@ 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 @@ -11,6 +12,7 @@ import kotlinx.coroutines.launch class FolderSettingsDataStore( private val folderRepository: FolderRepository, + private val account: Account, private var folder: FolderDetails ) : PreferenceDataStore() { private val saveScope = CoroutineScope(GlobalScope.coroutineContext + Dispatchers.IO) @@ -64,7 +66,7 @@ class FolderSettingsDataStore( private fun updateFolder(newFolder: FolderDetails) { folder = newFolder saveScope.launch { - folderRepository.updateFolderDetails(newFolder) + folderRepository.updateFolderDetails(account, newFolder) } } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/FolderSettingsViewModel.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/FolderSettingsViewModel.kt index 96b9b9135b2fb8d3d98529f899b7e68e6ac41177..7c047ecaa0035f05a0f06b00fef0a5375315bc16 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/FolderSettingsViewModel.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/FolderSettingsViewModel.kt @@ -11,7 +11,6 @@ import com.fsck.k9.helper.SingleLiveEvent import com.fsck.k9.mailstore.Folder import com.fsck.k9.mailstore.FolderDetails import com.fsck.k9.mailstore.FolderRepository -import com.fsck.k9.mailstore.FolderRepositoryManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import timber.log.Timber @@ -20,7 +19,7 @@ private const val NO_FOLDER_ID = 0L class FolderSettingsViewModel( private val preferences: Preferences, - private val folderRepositoryManager: FolderRepositoryManager, + private val folderRepository: FolderRepository, private val messagingController: MessagingController ) : ViewModel() { private val actionLiveData = SingleLiveEvent() @@ -44,8 +43,7 @@ class FolderSettingsViewModel( ): LiveData { return liveData(context = viewModelScope.coroutineContext) { val account = loadAccount(accountUuid) - val folderRepository = folderRepositoryManager.getFolderRepository(account) - val folderDetails = folderRepository.loadFolderDetails(folderId) + val folderDetails = folderRepository.loadFolderDetails(account, folderId) if (folderDetails == null) { Timber.w("Folder with ID $folderId not found") emit(FolderNotFound) @@ -57,7 +55,7 @@ class FolderSettingsViewModel( val folderSettingsData = FolderSettingsData( folder = folderDetails.folder, - dataStore = FolderSettingsDataStore(folderRepository, folderDetails) + dataStore = FolderSettingsDataStore(folderRepository, account, folderDetails) ) emit(folderSettingsData) } @@ -69,9 +67,9 @@ class FolderSettingsViewModel( } } - private suspend fun FolderRepository.loadFolderDetails(folderId: Long): FolderDetails? { + private suspend fun FolderRepository.loadFolderDetails(account: Account, folderId: Long): FolderDetails? { return withContext(Dispatchers.IO) { - getFolderDetails(folderId) + getFolderDetails(account, folderId) } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/KoinModule.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/KoinModule.kt index 7f9b1ca6348563ebba1199f4e9d027a195c0e23c..b92c10a5ff000ab4bb60aaa297cf2c510a88290e 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/KoinModule.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/KoinModule.kt @@ -4,6 +4,6 @@ import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module val manageFoldersUiModule = module { - viewModel { ManageFoldersViewModel(foldersLiveDataFactory = get()) } - viewModel { FolderSettingsViewModel(preferences = get(), folderRepositoryManager = get(), messagingController = get()) } + viewModel { ManageFoldersViewModel(folderRepository = get()) } + viewModel { FolderSettingsViewModel(preferences = get(), folderRepository = get(), messagingController = get()) } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/ManageFoldersFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/ManageFoldersFragment.kt index db0eceeaa70ece893377dce76ee51ffaea4c76d9..2607a868dfd69cd8ba73d12a1e84f123874f574a 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/ManageFoldersFragment.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/ManageFoldersFragment.kt @@ -144,9 +144,10 @@ class ManageFoldersFragment : Fragment() { if (constraint.isNullOrEmpty()) return true val locale = Locale.getDefault() - val displayName = item.displayName.toLowerCase(locale) + val displayName = item.displayName.lowercase(locale) return constraint.splitToSequence(" ") - .map { it.toLowerCase(locale) } + .filter { it.isNotEmpty() } + .map { it.lowercase(locale) } .any { it in displayName } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/ManageFoldersViewModel.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/ManageFoldersViewModel.kt index c938c48eb4ace5b9451d9cc81cca6aede76e5d62..8b0d310cd2ae838d93c664912d61885e712085da 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/ManageFoldersViewModel.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/managefolders/ManageFoldersViewModel.kt @@ -1,21 +1,14 @@ package com.fsck.k9.ui.managefolders +import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData import com.fsck.k9.Account -import com.fsck.k9.ui.folders.FoldersLiveData -import com.fsck.k9.ui.folders.FoldersLiveDataFactory +import com.fsck.k9.mailstore.DisplayFolder +import com.fsck.k9.mailstore.FolderRepository -class ManageFoldersViewModel(private val foldersLiveDataFactory: FoldersLiveDataFactory) : ViewModel() { - private var foldersLiveData: FoldersLiveData? = null - - fun getFolders(account: Account): FoldersLiveData { - val liveData = foldersLiveData - if (liveData != null && liveData.accountUuid == account.uuid) { - return liveData - } - - return foldersLiveDataFactory.create(account).also { - foldersLiveData = it - } +class ManageFoldersViewModel(private val folderRepository: FolderRepository) : ViewModel() { + fun getFolders(account: Account): LiveData> { + return folderRepository.getDisplayFoldersFlow(account).asLiveData() } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLoader.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLoader.kt index 5559dde7d939380b479be79c9d6be17acb77c491..7a5b6806b63259a0ad42584f30cff4f50b6dbb7e 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLoader.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLoader.kt @@ -40,6 +40,17 @@ class MessageListLoader( ) { fun getMessageList(config: MessageListConfig): MessageListInfo { + return try { + getMessageListInfo(config) + } catch (e: Exception) { + Timber.e(e, "Error while fetching message list") + + // TODO: Return an error object instead of an empty list + MessageListInfo(messageListItems = emptyList(), hasMoreMessages = false) + } + } + + private fun getMessageListInfo(config: MessageListConfig): MessageListInfo { val accounts = config.search.getAccounts(preferences) val cursors = accounts .mapNotNull { loadMessageListForAccount(it, config) } 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 021495938094a2e68d23dfeb6570fc9809ddbb45..fbacf67bd97653ad8f981634ad5d3404b473824a 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 @@ -1,6 +1,7 @@ package com.fsck.k9.ui.messageview; +import android.app.Activity; import android.content.Context; import android.util.AttributeSet; import android.view.View; @@ -13,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; @@ -120,8 +122,15 @@ public class AttachmentView extends FrameLayout implements OnClickListener { } public void refreshThumbnail() { + Context context = getContext(); + 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(getContext()) + Glide.with(context) .load(attachment.internalUri) .centerCrop() .diskCacheStrategy(DiskCacheStrategy.NONE) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/CryptoInfoDialog.java b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/CryptoInfoDialog.java index 45811c092acf5d1d7e4237fd7ccd54e7bf57af37..cdbc6d3b98419ad6fe51ecca45a3b72f6e90bf77 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/CryptoInfoDialog.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/CryptoInfoDialog.java @@ -109,12 +109,12 @@ public class CryptoInfoDialog extends DialogFragment { } private void setMessageForDisplayStatus(MessageCryptoDisplayStatus displayStatus) { - if (displayStatus.titleTextRes == null) { + if (displayStatus.getTitleTextRes() == null) { throw new AssertionError("Crypto info dialog can only be displayed for items with text!"); } - setMessageSingleLine(displayStatus.colorAttr, displayStatus.titleTextRes, displayStatus.descriptionTextRes, - displayStatus.statusIconRes); + setMessageSingleLine(displayStatus.getColorAttr(), displayStatus.getTitleTextRes(), + displayStatus.getDescriptionTextRes(), displayStatus.getStatusIconRes()); } private void setMessageSingleLine(@AttrRes int colorAttr, @StringRes int titleTextRes, 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/notification/DeleteConfirmationActivity.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/notification/DeleteConfirmationActivity.kt index 9101760df48847891a377abf633fd598751f8d54..380dd48303dbee80f672f9b37dbec21f0e62b80b 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/notification/DeleteConfirmationActivity.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/notification/DeleteConfirmationActivity.kt @@ -99,12 +99,10 @@ class DeleteConfirmationActivity : K9Activity(ThemeType.DIALOG), ConfirmationDia private const val DIALOG_ID = 1 private const val DIALOG_TAG = "dialog" - @JvmStatic fun getIntent(context: Context, messageReference: MessageReference): Intent { return getIntent(context, listOf(messageReference)) } - @JvmStatic fun getIntent(context: Context, messageReferences: List): Intent { val accountUuid = messageReferences[0].accountUuid val messageReferenceStrings = MessageReferenceHelper.toMessageReferenceStringList(messageReferences) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/onboarding/OnboardingActivity.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/onboarding/OnboardingActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..00952cd438a4214a5ceb91169bd34036925c8a05 --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/onboarding/OnboardingActivity.kt @@ -0,0 +1,43 @@ +package com.fsck.k9.ui.onboarding + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import androidx.navigation.NavController +import androidx.navigation.ui.AppBarConfiguration +import androidx.navigation.ui.setupActionBarWithNavController +import com.fsck.k9.ui.R +import com.fsck.k9.ui.base.K9Activity +import com.fsck.k9.ui.base.extensions.findNavController + +class OnboardingActivity : K9Activity() { + private lateinit var navController: NavController + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setLayout(R.layout.activity_onboarding) + setTitle(R.string.account_setup_basics_title) + + initializeActionBar() + } + + private fun initializeActionBar() { + val appBarConfiguration = AppBarConfiguration(topLevelDestinationIds = setOf(R.id.welcomeScreen)) + + navController = findNavController(R.id.nav_host_fragment) + setupActionBarWithNavController(navController, appBarConfiguration) + } + + override fun onSupportNavigateUp(): Boolean { + return navController.navigateUp() || super.onSupportNavigateUp() + } + + companion object { + @JvmStatic fun launch(activity: Activity) { + val intent = Intent(activity, OnboardingActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + activity.startActivity(intent) + } + } +} \ No newline at end of file diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/onboarding/WelcomeFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/onboarding/WelcomeFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..41629b9b6dd5f9dbc375e9f3edfbbe7f9b1ef3ba --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/onboarding/WelcomeFragment.kt @@ -0,0 +1,62 @@ +package com.fsck.k9.ui.onboarding + +import android.os.Bundle +import android.text.method.LinkMovementMethod +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import com.fsck.k9.ui.BuildConfig +import com.fsck.k9.ui.R +import com.fsck.k9.ui.helper.HtmlToSpanned +import com.fsck.k9.ui.observeNotNull +import com.fsck.k9.ui.settings.import.SettingsImportResultViewModel +import com.fsck.k9.ui.settings.import.SettingsImportSuccess +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.sharedViewModel + +class WelcomeFragment : Fragment() { + private val htmlToSpanned: HtmlToSpanned by inject() + private val importResultViewModel: SettingsImportResultViewModel by sharedViewModel() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_welcome_message, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val welcome: TextView = view.findViewById(R.id.welcome_message) + welcome.text = htmlToSpanned.convert(getString(R.string.accounts_welcome)) + welcome.movementMethod = LinkMovementMethod.getInstance() + + view.findViewById(R.id.next).setOnClickListener { launchAccountSetup() } + view.findViewById(R.id.import_settings).setOnClickListener { launchImportSettings() } + + importResultViewModel.settingsImportResult.observeNotNull(this) { + if (it == SettingsImportSuccess) { + launchMessageList() + } + } + } + + private fun launchAccountSetup() { + if (BuildConfig.USE_NEW_SETUP_UI_FOR_ONBOARDING) { + findNavController().navigate(R.id.action_welcomeScreen_to_newAddAccountScreen) + } else { + findNavController().navigate(R.id.action_welcomeScreen_to_addAccountScreen) + requireActivity().finish() + } + } + + private fun launchImportSettings() { + findNavController().navigate(R.id.action_welcomeScreen_to_settingsImportScreen) + } + + private fun launchMessageList() { + findNavController().navigate(R.id.action_welcomeScreen_to_messageListScreen) + requireActivity().finish() + } +} \ No newline at end of file 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..c86b5b37112c45a9494ae5fdbf807ac7fb302372 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,16 +15,23 @@ 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()) } - factory { GeneralSettingsDataStore(jobManager = get(), themeManager = get(), appLanguageManager = get()) } + viewModel { GeneralSettingsViewModel(logFileWriter = get()) } + factory { GeneralSettingsDataStore(jobManager = get(), appLanguageManager = get(), generalSettingsManager = get()) } single(named("SaveSettingsExecutorService")) { Executors.newSingleThreadExecutor(NamedThreadFactory("SaveSettings")) } viewModel { AccountSettingsViewModel(get(), get(), get()) } - single { AccountSettingsDataStoreFactory(get(), get(), get(named("SaveSettingsExecutorService"))) } + single { + AccountSettingsDataStoreFactory( + preferences = get(), + jobManager = get(), + executorService = get(named("SaveSettingsExecutorService")), + notificationChannelManager = get() + ) + } viewModel { SettingsExportViewModel(context = get(), preferences = get(), settingsExporter = get()) } viewModel { SettingsImportViewModel(get(), get()) } 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/AccountSettingsDataStore.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsDataStore.kt index c39d5cec82772748e51abf0cd35b940d0e3751f8..1dbbb441294344884164df038cf6d51639a99be5 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsDataStore.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsDataStore.kt @@ -5,18 +5,20 @@ import com.fsck.k9.Account import com.fsck.k9.Account.SpecialFolderSelection import com.fsck.k9.Preferences import com.fsck.k9.job.K9JobManager +import com.fsck.k9.notification.NotificationChannelManager import java.util.concurrent.ExecutorService class AccountSettingsDataStore( private val preferences: Preferences, private val executorService: ExecutorService, private val account: Account, - private val jobManager: K9JobManager + private val jobManager: K9JobManager, + private val notificationChannelManager: NotificationChannelManager ) : PreferenceDataStore() { + private var notificationSettingsChanged = false override fun getBoolean(key: String, defValue: Boolean): Boolean { return when (key) { - "account_default" -> account == preferences.defaultAccount "mark_message_as_read_on_view" -> account.isMarkMessageAsReadOnView "mark_message_as_read_on_delete" -> account.isMarkMessageAsReadOnDelete "account_sync_remote_deletetions" -> account.isSyncRemoteDeletions @@ -37,18 +39,13 @@ class AccountSettingsDataStore( "remote_search_enabled" -> account.isAllowRemoteSearch "autocrypt_prefer_encrypt" -> account.autocryptPreferEncryptMutual "upload_sent_messages" -> account.isUploadSentMessages + "ignore_chat_messages" -> account.isIgnoreChatMessages else -> defValue } } override fun putBoolean(key: String, value: Boolean) { when (key) { - "account_default" -> { - executorService.execute { - preferences.defaultAccount = account - } - return - } "mark_message_as_read_on_view" -> account.isMarkMessageAsReadOnView = value "mark_message_as_read_on_delete" -> account.isMarkMessageAsReadOnDelete = value "account_sync_remote_deletetions" -> account.isSyncRemoteDeletions = value @@ -69,6 +66,7 @@ class AccountSettingsDataStore( "openpgp_encrypt_all_drafts" -> account.isOpenPgpEncryptAllDrafts = value "autocrypt_prefer_encrypt" -> account.autocryptPreferEncryptMutual = value "upload_sent_messages" -> account.isUploadSentMessages = value + "ignore_chat_messages" -> account.isIgnoreChatMessages = value else -> return } @@ -86,7 +84,7 @@ class AccountSettingsDataStore( override fun putInt(key: String?, value: Int) { when (key) { "chip_color" -> account.chipColor = value - "led_color" -> account.notificationSetting.ledColor = value + "led_color" -> setNotificationLightColor(value) else -> return } @@ -138,8 +136,7 @@ class AccountSettingsDataStore( "spam_folder" -> loadSpecialFolder(account.spamFolderId, account.spamFolderSelection) "trash_folder" -> loadSpecialFolder(account.trashFolderId, account.trashFolderSelection) "folder_notify_new_mail_mode" -> account.folderNotifyNewMailMode.name - "account_vibrate_pattern" -> account.notificationSetting.vibratePattern.toString() - "account_vibrate_times" -> account.notificationSetting.vibrateTimes.toString() + "account_combined_vibration_pattern" -> getCombinedVibrationPattern() "account_remote_search_num_results" -> account.remoteSearchNumResults.toString() "account_ringtone" -> account.notificationSetting.ringtone else -> defValue @@ -183,8 +180,7 @@ class AccountSettingsDataStore( "spam_folder" -> saveSpecialFolderSelection(value, account::setSpamFolderId) "trash_folder" -> saveSpecialFolderSelection(value, account::setTrashFolderId) "folder_notify_new_mail_mode" -> account.folderNotifyNewMailMode = Account.FolderMode.valueOf(value) - "account_vibrate_pattern" -> account.notificationSetting.vibratePattern = value.toInt() - "account_vibrate_times" -> account.notificationSetting.vibrateTimes = value.toInt() + "account_combined_vibration_pattern" -> setCombinedVibrationPattern(value) "account_remote_search_num_results" -> account.remoteSearchNumResults = value.toInt() "account_ringtone" -> with(account.notificationSetting) { isRingEnabled = true @@ -196,8 +192,20 @@ class AccountSettingsDataStore( saveSettingsInBackground() } + private fun setNotificationLightColor(value: Int) { + if (account.notificationSetting.ledColor != value) { + account.notificationSetting.ledColor = value + notificationSettingsChanged = true + } + } + fun saveSettingsInBackground() { executorService.execute { + if (notificationSettingsChanged) { + notificationChannelManager.recreateMessagesNotificationChannel(account) + } + + notificationSettingsChanged = false saveSettings() } } @@ -238,4 +246,18 @@ class AccountSettingsDataStore( return prefix + (specialFolderId?.toString() ?: FolderListPreference.NO_FOLDER_VALUE) } + + private fun getCombinedVibrationPattern(): String { + return VibrationPatternPreference.encode( + vibrationPattern = account.notificationSetting.vibratePattern, + vibrationTimes = account.notificationSetting.vibrateTimes + ) + } + + private fun setCombinedVibrationPattern(value: String) { + val (vibrationPattern, vibrationTimes) = VibrationPatternPreference.decode(value) + account.notificationSetting.vibratePattern = vibrationPattern + account.notificationSetting.vibrateTimes = vibrationTimes + notificationSettingsChanged = true + } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsDataStoreFactory.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsDataStoreFactory.kt index 852adc7af43e39ff343ef67cc69a419a450f79bd..f6439be45fcea5d641b5c2291a0f1f2a336724ef 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsDataStoreFactory.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsDataStoreFactory.kt @@ -3,14 +3,16 @@ package com.fsck.k9.ui.settings.account import com.fsck.k9.Account import com.fsck.k9.Preferences import com.fsck.k9.job.K9JobManager +import com.fsck.k9.notification.NotificationChannelManager import java.util.concurrent.ExecutorService class AccountSettingsDataStoreFactory( private val preferences: Preferences, private val jobManager: K9JobManager, - private val executorService: ExecutorService + private val executorService: ExecutorService, + private val notificationChannelManager: NotificationChannelManager ) { fun create(account: Account): AccountSettingsDataStore { - return AccountSettingsDataStore(preferences, executorService, account, jobManager) + return AccountSettingsDataStore(preferences, executorService, account, jobManager, notificationChannelManager) } } 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 d6e5e7ae11a3bf399085f6900e98e569e15a9933..8c7de21753de6dde27a897312918dd7e235db773 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 @@ -1,14 +1,18 @@ package com.fsck.k9.ui.settings.account +import android.annotation.SuppressLint import android.content.Intent import android.os.Build import android.os.Bundle +import android.os.Vibrator import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.widget.Toast +import androidx.core.content.getSystemService import androidx.preference.ListPreference import androidx.preference.Preference +import androidx.preference.PreferenceCategory import androidx.preference.SwitchPreference import com.fsck.k9.Account import com.fsck.k9.account.BackgroundAccountRemover @@ -22,6 +26,8 @@ import com.fsck.k9.fragment.ConfirmationDialogFragment import com.fsck.k9.fragment.ConfirmationDialogFragment.ConfirmationDialogFragmentListener import com.fsck.k9.mailstore.FolderType import com.fsck.k9.mailstore.RemoteFolder +import com.fsck.k9.notification.NotificationChannelManager +import com.fsck.k9.notification.NotificationChannelManager.ChannelType import com.fsck.k9.ui.R import com.fsck.k9.ui.endtoend.AutocryptKeyTransferActivity import com.fsck.k9.ui.settings.onClick @@ -29,6 +35,7 @@ import com.fsck.k9.ui.settings.oneTimeClickListener import com.fsck.k9.ui.settings.remove import com.fsck.k9.ui.settings.removeEntry import com.fsck.k9.ui.withArguments +import com.takisoft.preferencex.ColorPickerPreference import com.takisoft.preferencex.PreferenceFragmentCompat import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.sharedViewModel @@ -43,7 +50,11 @@ class AccountSettingsFragment : PreferenceFragmentCompat(), ConfirmationDialogFr private val openPgpApiManager: OpenPgpApiManager by inject { parametersOf(this) } private val messagingController: MessagingController by inject() private val accountRemover: BackgroundAccountRemover by inject() + private val notificationChannelManager: NotificationChannelManager by inject() + private val vibrator by lazy { requireContext().getSystemService() } private lateinit var dataStore: AccountSettingsDataStore + private var notificationLightColorPreference: ColorPickerPreference? = null + private var notificationVibrationPatternPreference: VibrationPatternPreference? = null private val accountUuid: String by lazy { checkNotNull(arguments?.getString(ARG_ACCOUNT_UUID)) { "$ARG_ACCOUNT_UUID == null" } @@ -71,7 +82,7 @@ class AccountSettingsFragment : PreferenceFragmentCompat(), ConfirmationDialogFr initializeAdvancedPushSettings(account) initializeCryptoSettings(account) initializeFolderSettings(account) - initializeNotifications() + initializeNotifications(account) } override fun onActivityCreated(savedInstanceState: Bundle?) { @@ -85,6 +96,8 @@ class AccountSettingsFragment : PreferenceFragmentCompat(), ConfirmationDialogFr // we might be returning from OpenPgpAppSelectDialog, make sure settings are up to date val account = getAccount() initializeCryptoSettings(account) + + maybeUpdateNotificationPreferences(account) } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { @@ -176,12 +189,66 @@ class AccountSettingsFragment : PreferenceFragmentCompat(), ConfirmationDialogFr } } - private fun initializeNotifications() { - findPreference(PREFERENCE_OPEN_NOTIFICATION_SETTINGS)?.let { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - PRE_SDK26_NOTIFICATION_PREFERENCES.forEach { findPreference(it).remove() } - } else { - it.remove() + private fun initializeNotifications(account: Account) { + if (vibrator?.hasVibrator() != true) { + findPreference(PREFERENCE_NOTIFICATION_VIBRATION_PATTERN)?.remove() + findPreference(PREFERENCE_NOTIFICATION_ENABLE_VIBRATION)?.remove() + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + findPreference(PREFERENCE_NOTIFICATION_LIGHT_COLOR)?.let { preference -> + notificationLightColorPreference = preference + preference.dependency = null + preference.isEnabled = false + } + + findPreference(PREFERENCE_NOTIFICATION_VIBRATION_PATTERN)?.let { preference -> + notificationVibrationPatternPreference = preference + preference.dependency = null + preference.isEnabled = false + } + + PRE_SDK26_NOTIFICATION_PREFERENCES.forEach { findPreference(it).remove() } + + findPreference(PREFERENCE_NOTIFICATION_SETTINGS_MESSAGES)?.let { + it.notificationChannelIdProvider = { + notificationChannelManager.getChannelIdFor(account, ChannelType.MESSAGES) + } + } + + findPreference(PREFERENCE_NOTIFICATION_SETTINGS_MISCELLANEOUS)?.let { + it.notificationChannelIdProvider = { + notificationChannelManager.getChannelIdFor(account, ChannelType.MISCELLANEOUS) + } + } + } else { + findPreference(PREFERENCE_NOTIFICATION_CHANNELS).remove() + } + } + + private fun maybeUpdateNotificationPreferences(account: Account) { + if (notificationLightColorPreference != null || notificationVibrationPatternPreference != null) { + updateNotificationPreferences(account) + } + } + + @SuppressLint("NewApi") + private fun updateNotificationPreferences(account: Account) { + val notificationConfiguration = notificationChannelManager.getNotificationConfiguration(account) + + notificationLightColorPreference?.let { preference -> + val blinkLightsEnabled = notificationConfiguration.isBlinkLightsEnabled + preference.isEnabled = blinkLightsEnabled + if (blinkLightsEnabled) { + preference.color = notificationConfiguration.lightColor + } + } + + notificationVibrationPatternPreference?.let { preference -> + val vibrationEnabled = notificationConfiguration.isVibrationEnabled + preference.isEnabled = vibrationEnabled + if (vibrationEnabled) { + preference.setVibrationPatternFromSystem(notificationConfiguration.vibrationPattern) } } } @@ -373,16 +440,18 @@ class AccountSettingsFragment : PreferenceFragmentCompat(), ConfirmationDialogFr private const val PREFERENCE_SENT_FOLDER = "sent_folder" private const val PREFERENCE_SPAM_FOLDER = "spam_folder" private const val PREFERENCE_TRASH_FOLDER = "trash_folder" - private const val PREFERENCE_OPEN_NOTIFICATION_SETTINGS = "open_notification_settings" + private const val PREFERENCE_NOTIFICATION_LIGHT_COLOR = "led_color" + private const val PREFERENCE_NOTIFICATION_ENABLE_VIBRATION = "account_vibrate" + private const val PREFERENCE_NOTIFICATION_VIBRATION_PATTERN = "account_combined_vibration_pattern" + private const val PREFERENCE_NOTIFICATION_CHANNELS = "notification_channels" + private const val PREFERENCE_NOTIFICATION_SETTINGS_MESSAGES = "open_notification_settings_messages" + private const val PREFERENCE_NOTIFICATION_SETTINGS_MISCELLANEOUS = "open_notification_settings_miscellaneous" private const val DELETE_POLICY_MARK_AS_READ = "MARK_AS_READ" private val PRE_SDK26_NOTIFICATION_PREFERENCES = arrayOf( "account_ringtone", - "account_vibrate", - "account_vibrate_pattern", - "account_vibrate_times", + PREFERENCE_NOTIFICATION_ENABLE_VIBRATION, "account_led", - "led_color" ) private const val DIALOG_DELETE_ACCOUNT = 1 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 b24c8d8356fdabcb1126948e8a5fd90861c97e0c..7930f383b2d31b6d80b5b80de88f0ae2808c339b 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,34 +3,38 @@ 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.FolderRepositoryManager +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 folderRepositoryManager: FolderRepositoryManager, - private val specialFolderSelectionStrategy: SpecialFolderSelectionStrategy + private val accountManager: AccountManager, + private val folderRepository: FolderRepository, + 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) { - accountLiveData.value = 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 } } @@ -42,13 +46,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 { @@ -60,13 +67,13 @@ class AccountSettingsViewModel( } private fun loadFolders(account: Account) { - val folderRepository = folderRepositoryManager.getFolderRepository(account) - GlobalScope.launch(Dispatchers.Main) { - foldersLiveData.value = withContext(Dispatchers.IO) { - val folders = folderRepository.getRemoteFolders() + viewModelScope.launch { + val remoteFolderInfo = withContext(backgroundDispatcher) { + val folders = folderRepository.getRemoteFolders(account) val automaticSpecialFolders = getAutomaticSpecialFolders(folders) RemoteFolderInfo(folders, automaticSpecialFolders) } + foldersLiveData.value = remoteFolderInfo } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/NotificationSoundPreference.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/NotificationSoundPreference.kt new file mode 100644 index 0000000000000000000000000000000000000000..1ba0fc109e895b02423e5709ff89f2ae137527b6 --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/NotificationSoundPreference.kt @@ -0,0 +1,69 @@ +package com.fsck.k9.ui.settings.account + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.media.RingtoneManager +import android.net.Uri +import android.util.AttributeSet +import androidx.core.content.res.TypedArrayUtils +import androidx.preference.Preference +import com.takisoft.preferencex.PreferenceActivityResultListener +import com.takisoft.preferencex.PreferenceFragmentCompat + +private const val REQUEST_CODE_RINGTONE = 1 + +@SuppressLint("RestrictedApi") +class NotificationSoundPreference +@JvmOverloads +constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = TypedArrayUtils.getAttr( + context, + androidx.preference.R.attr.preferenceStyle, + android.R.attr.preferenceStyle + ), + defStyleRes: Int = 0 +) : Preference(context, attrs, defStyleAttr, defStyleRes), PreferenceActivityResultListener { + + override fun onPreferenceClick(fragment: PreferenceFragmentCompat, preference: Preference) { + launchRingtonePicker(fragment, onRestoreRingtone()) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode != REQUEST_CODE_RINGTONE || resultCode != Activity.RESULT_OK) return + + val uri = data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI) + + if (callChangeListener(uri?.toString().orEmpty())) { + onSaveRingtone(uri) + } + } + + @Suppress("DEPRECATION") + private fun launchRingtonePicker(fragment: PreferenceFragmentCompat, selectedRingtone: Uri?) { + val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER) + .putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION) + .putExtra( + RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + ) + .putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, title) + .putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true) + .putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true) + .putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, selectedRingtone) + + fragment.startActivityForResult(intent, REQUEST_CODE_RINGTONE) + } + + private fun onRestoreRingtone(): Uri? { + val uriString = getPersistedString(null)?.takeIf { it.isNotEmpty() } + return uriString?.let { Uri.parse(it) } + } + + private fun onSaveRingtone(ringtoneUri: Uri?) { + persistString(ringtoneUri?.toString().orEmpty()) + } +} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/NotificationsPreference.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/NotificationsPreference.kt index b664bf3573d847eefd976e86355001944f99fdaf..4322a6051a6a0f3c89cc3170c660b4cd8a3dc9bb 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/NotificationsPreference.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/NotificationsPreference.kt @@ -3,15 +3,20 @@ package com.fsck.k9.ui.settings.account import android.annotation.SuppressLint import android.content.Context import android.content.Intent +import android.os.Build import android.provider.Settings import android.util.AttributeSet +import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat.startActivity import androidx.core.content.res.TypedArrayUtils import androidx.fragment.app.DialogFragment import androidx.preference.Preference import com.takisoft.preferencex.PreferenceFragmentCompat +typealias NotificationChannelIdProvider = () -> String + @SuppressLint("RestrictedApi") +@RequiresApi(Build.VERSION_CODES.O) class NotificationsPreference @JvmOverloads constructor( @@ -24,10 +29,21 @@ constructor( defStyleRes: Int = 0 ) : Preference(context, attrs, defStyleAttr, defStyleRes) { + var notificationChannelIdProvider: NotificationChannelIdProvider? = null + override fun onClick() { - val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) - intent.putExtra(Settings.EXTRA_APP_PACKAGE, this.context.packageName) - startActivity(this.context, intent, null) + notificationChannelIdProvider.let { provider -> + val intent = if (provider == null) { + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + } else { + val notificationChannelId = provider.invoke() + Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_CHANNEL_ID, notificationChannelId) + } + } + intent.putExtra(Settings.EXTRA_APP_PACKAGE, this.context.packageName) + startActivity(this.context, intent, null) + } } companion object { diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/VibrationPatternDialogFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/VibrationPatternDialogFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..2a80ce52e86e3f9e5c4720d0d15a1a9cf9e588ea --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/VibrationPatternDialogFragment.kt @@ -0,0 +1,173 @@ +package com.fsck.k9.ui.settings.account + +import android.app.Dialog +import android.os.Build +import android.os.Bundle +import android.os.VibrationEffect +import android.os.Vibrator +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter +import android.widget.CheckedTextView +import android.widget.SeekBar +import android.widget.SeekBar.OnSeekBarChangeListener +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.core.content.getSystemService +import androidx.preference.PreferenceDialogFragmentCompat +import com.fsck.k9.NotificationSetting +import com.fsck.k9.ui.R + +class VibrationPatternDialogFragment : PreferenceDialogFragmentCompat() { + private val vibrator by lazy { requireContext().getSystemService() ?: error("Vibrator service missing") } + + private val vibratePatternPreference: VibrationPatternPreference + get() = preference as VibrationPatternPreference + + private lateinit var adapter: VibrationPatternAdapter + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val context = requireContext() + + val vibrationPattern: Int + val vibrationTimes: Int + if (savedInstanceState != null) { + vibrationPattern = savedInstanceState.getInt(STATE_VIBRATION_PATTERN) + vibrationTimes = savedInstanceState.getInt(STATE_VIBRATION_TIMES) + } else { + vibrationPattern = vibratePatternPreference.vibrationPattern + vibrationTimes = vibratePatternPreference.vibrationTimes + } + + adapter = VibrationPatternAdapter( + entries = vibratePatternPreference.entries.map { it.toString() }, + entryValues = vibratePatternPreference.entryValues.map { it.toString().toInt() }, + vibrationPattern, + vibrationTimes + ) + + return AlertDialog.Builder(context) + .setTitle(preference.title) + .setAdapter(adapter, null) + .setPositiveButton(R.string.okay_action, ::onClick) + .setNegativeButton(R.string.cancel_action, ::onClick) + .create() + } + + override fun onDialogClosed(positiveResult: Boolean) { + if (positiveResult) { + vibratePatternPreference.setVibrationPattern(adapter.vibrationPattern, adapter.vibrationTimes) + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putInt(STATE_VIBRATION_PATTERN, adapter.vibrationPattern) + outState.putInt(STATE_VIBRATION_TIMES, adapter.vibrationTimes) + } + + private fun playVibration() { + val vibrationPattern = adapter.vibrationPattern + val vibrationTimes = adapter.vibrationTimes + val combinedPattern = NotificationSetting.getVibration(vibrationPattern, vibrationTimes) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val vibrationEffect = VibrationEffect.createWaveform(combinedPattern, -1) + vibrator.vibrate(vibrationEffect) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(combinedPattern, -1) + } + } + + private inner class VibrationPatternAdapter( + private val entries: List, + private val entryValues: List, + initialVibrationPattern: Int, + initialVibrationTimes: Int + ) : BaseAdapter() { + private var checkedPosition = entryValues.indexOf(initialVibrationPattern).takeIf { it != -1 } ?: 0 + + val vibrationPattern: Int + get() = entryValues[checkedPosition] + + var vibrationTimes = initialVibrationTimes + + override fun hasStableIds(): Boolean = true + + override fun getItemId(position: Int): Long = position.toLong() + + override fun getItemViewType(position: Int): Int { + return if (position < entries.size) 0 else 1 + } + + override fun getViewTypeCount(): Int = 2 + + override fun getCount(): Int = entries.size + 1 + + override fun getItem(position: Int): Any? { + return if (position < entries.size) entries[position] else null + } + + override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View { + val itemType = getItemViewType(position) + return if (itemType == 0) { + getVibrationPatternView(position, convertView, parent) + } else { + getVibrationTimesView(convertView, parent) + } + } + + private fun getVibrationPatternView(position: Int, convertView: View?, parent: ViewGroup?): View { + return convertView.orInflate(R.layout.preference_vibration_pattern_item, parent) + .apply { + text = getItem(position) as String + isChecked = position == checkedPosition + setOnClickListener { + checkedPosition = position + playVibration() + notifyDataSetChanged() + } + } + } + + private fun getVibrationTimesView(convertView: View?, parent: ViewGroup?): View { + return convertView.orInflate(R.layout.preference_vibration_times_item, parent).apply { + val vibrationTimesValue = findViewById(R.id.vibrationTimesValue) + vibrationTimesValue.text = vibrationTimes.toString() + + val vibrationTimesSeekBar = findViewById(R.id.vibrationTimesSeekBar) + val progress = vibrationTimes - 1 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + vibrationTimesSeekBar.setProgress(progress, false) + } else { + vibrationTimesSeekBar.progress = progress + } + + vibrationTimesSeekBar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + vibrationTimes = progress + 1 + vibrationTimesValue.text = vibrationTimes.toString() + } + + override fun onStartTrackingTouch(seekBar: SeekBar) = Unit + + override fun onStopTrackingTouch(seekBar: SeekBar) { + playVibration() + } + }) + } + } + + @Suppress("UNCHECKED_CAST") + private fun View?.orInflate(layoutResId: Int, parent: ViewGroup?): T { + val view = this ?: layoutInflater.inflate(layoutResId, parent, false) + return view as T + } + } + + companion object { + private const val STATE_VIBRATION_PATTERN = "vibrationPattern" + private const val STATE_VIBRATION_TIMES = "vibrationTimes" + } +} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/VibrationPatternPreference.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/VibrationPatternPreference.kt new file mode 100644 index 0000000000000000000000000000000000000000..33ca1dae796ed63bcd922912992077268ecdb07b --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/VibrationPatternPreference.kt @@ -0,0 +1,100 @@ +package com.fsck.k9.ui.settings.account + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import androidx.core.content.res.TypedArrayUtils +import androidx.preference.ListPreference +import com.fsck.k9.NotificationSetting +import com.takisoft.preferencex.PreferenceFragmentCompat + +/** + * Allows selecting a vibration pattern and specifying how often the vibration should repeat. + */ +@SuppressLint("RestrictedApi") +class VibrationPatternPreference +@JvmOverloads +constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = TypedArrayUtils.getAttr( + context, + androidx.preference.R.attr.preferenceStyle, + android.R.attr.preferenceStyle + ), + defStyleRes: Int = 0 +) : ListPreference(context, attrs, defStyleAttr, defStyleRes) { + internal var vibrationPattern: Int = DEFAULT_VIBRATION_PATTERN + private set + + internal var vibrationTimes: Int = DEFAULT_VIBRATION_TIMES + private set + + override fun onSetInitialValue(defaultValue: Any?) { + val encoded = getPersistedString(defaultValue as String?) + val (vibrationPattern, vibrationTimes) = decode(encoded) + + this.vibrationPattern = vibrationPattern + this.vibrationTimes = vibrationTimes + + updateSummary() + } + + override fun onClick() { + preferenceManager.showDialog(this) + } + + fun setVibrationPattern(vibrationPattern: Int, vibrationTimes: Int) { + this.vibrationPattern = vibrationPattern + this.vibrationTimes = vibrationTimes + + val encoded = encode(vibrationPattern, vibrationTimes) + persistString(encoded) + + updateSummary() + } + + fun setVibrationPatternFromSystem(combinedPattern: List?) { + if (combinedPattern == null || combinedPattern.size < 2 || combinedPattern.size % 2 != 0) { + setVibrationPattern(DEFAULT_VIBRATION_PATTERN, DEFAULT_VIBRATION_TIMES) + return + } + + val combinedPatternArray = combinedPattern.toLongArray() + val vibrationTimes = combinedPattern.size / 2 + val vibrationPattern = entryValues.asSequence() + .map { entryValue -> entryValue.toString().toInt() } + .firstOrNull { vibrationPattern -> + val testPattern = NotificationSetting.getVibration(vibrationPattern, vibrationTimes) + + testPattern.contentEquals(combinedPatternArray) + } ?: DEFAULT_VIBRATION_PATTERN + + setVibrationPattern(vibrationPattern, vibrationTimes) + } + + private fun updateSummary() { + val index = entryValues.indexOf(vibrationPattern.toString()) + summary = entries[index] + } + + companion object { + private const val DEFAULT_VIBRATION_PATTERN = 0 + private const val DEFAULT_VIBRATION_TIMES = 1 + + init { + PreferenceFragmentCompat.registerPreferenceFragment( + VibrationPatternPreference::class.java, VibrationPatternDialogFragment::class.java + ) + } + + fun encode(vibrationPattern: Int, vibrationTimes: Int): String { + return "$vibrationPattern|$vibrationTimes" + } + + fun decode(encoded: String): Pair { + val (vibrationPattern, vibrationTimes) = encoded.split('|').map { it.toInt() } + return Pair(vibrationPattern, vibrationTimes) + } + } +} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsDataStore.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsDataStore.kt index a85a17a57ee9b32046dfb48f4d67e83a9398a39e..a38bed60c202efa329c5702b445551d8e643d300 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsDataStore.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/general/GeneralSettingsDataStore.kt @@ -2,23 +2,26 @@ package com.fsck.k9.ui.settings.general import androidx.preference.PreferenceDataStore import com.fsck.k9.K9 -import com.fsck.k9.K9.AppTheme -import com.fsck.k9.K9.SubTheme import com.fsck.k9.job.K9JobManager +import com.fsck.k9.preferences.AppTheme +import com.fsck.k9.preferences.GeneralSettingsManager +import com.fsck.k9.preferences.SubTheme import com.fsck.k9.ui.base.AppLanguageManager -import com.fsck.k9.ui.base.ThemeManager class GeneralSettingsDataStore( private val jobManager: K9JobManager, - private val themeManager: ThemeManager, - private val appLanguageManager: AppLanguageManager + private val appLanguageManager: AppLanguageManager, + private val generalSettingsManager: GeneralSettingsManager ) : PreferenceDataStore() { + private var skipSaveSettings = false + override fun getBoolean(key: String, defValue: Boolean): Boolean { return when (key) { - "fixed_message_view_theme" -> K9.isFixedMessageViewTheme + "fixed_message_view_theme" -> generalSettingsManager.getSettings().fixedMessageViewTheme "animations" -> K9.isShowAnimations "show_unified_inbox" -> K9.isShowUnifiedInbox + "show_starred_count" -> K9.isShowStarredCount "messagelist_stars" -> K9.isShowMessageListStars "messagelist_show_correspondent_names" -> K9.isShowCorrespondentNames "messagelist_sender_above_subject" -> K9.isMessageListSenderAboveSubject @@ -45,9 +48,10 @@ class GeneralSettingsDataStore( override fun putBoolean(key: String, value: Boolean) { when (key) { - "fixed_message_view_theme" -> K9.isFixedMessageViewTheme = value + "fixed_message_view_theme" -> setFixedMessageViewTheme(value) "animations" -> K9.isShowAnimations = value "show_unified_inbox" -> K9.isShowUnifiedInbox = value + "show_starred_count" -> K9.isShowStarredCount = value "messagelist_stars" -> K9.isShowMessageListStars = value "messagelist_show_correspondent_names" -> K9.isShowCorrespondentNames = value "messagelist_sender_above_subject" -> K9.isMessageListSenderAboveSubject = value @@ -95,15 +99,14 @@ class GeneralSettingsDataStore( override fun getString(key: String, defValue: String?): String? { return when (key) { "language" -> appLanguageManager.getAppLanguage() - "theme" -> appThemeToString(K9.appTheme) - "message_compose_theme" -> subThemeToString(K9.messageComposeTheme) - "messageViewTheme" -> subThemeToString(K9.messageViewTheme) + "theme" -> appThemeToString(generalSettingsManager.getSettings().appTheme) + "message_compose_theme" -> subThemeToString(generalSettingsManager.getSettings().messageComposeTheme) + "messageViewTheme" -> subThemeToString(generalSettingsManager.getSettings().messageViewTheme) "messagelist_preview_lines" -> K9.messageListPreviewLines.toString() "splitview_mode" -> K9.splitViewMode.name "notification_quick_delete" -> K9.notificationQuickDeleteBehaviour.name "lock_screen_notification_visibility" -> K9.lockScreenNotificationVisibility.name "background_ops" -> K9.backgroundOps.name - "notification_hide_subject" -> K9.notificationHideSubject.name "quiet_time_starts" -> K9.quietTimeStarts "quiet_time_ends" -> K9.quietTimeEnds "account_name_font" -> K9.fontSizes.accountName.toString() @@ -132,8 +135,8 @@ class GeneralSettingsDataStore( when (key) { "language" -> appLanguageManager.setAppLanguage(value) "theme" -> setTheme(value) - "message_compose_theme" -> K9.messageComposeTheme = stringToSubTheme(value) - "messageViewTheme" -> K9.messageViewTheme = stringToSubTheme(value) + "message_compose_theme" -> setMessageComposeTheme(value) + "messageViewTheme" -> setMessageViewTheme(value) "messagelist_preview_lines" -> K9.messageListPreviewLines = value.toInt() "splitview_mode" -> K9.splitViewMode = K9.SplitViewMode.valueOf(value) "notification_quick_delete" -> { @@ -143,7 +146,6 @@ class GeneralSettingsDataStore( K9.lockScreenNotificationVisibility = K9.LockScreenNotificationVisibility.valueOf(value) } "background_ops" -> setBackgroundOps(value) - "notification_hide_subject" -> K9.notificationHideSubject = K9.NotificationHideSubject.valueOf(value) "quiet_time_starts" -> K9.quietTimeStarts = value "quiet_time_ends" -> K9.quietTimeEnds = value "account_name_font" -> K9.fontSizes.accountName = value.toInt() @@ -228,12 +230,31 @@ class GeneralSettingsDataStore( } private fun saveSettings() { - K9.saveSettingsAsync() + if (skipSaveSettings) { + skipSaveSettings = false + } else { + K9.saveSettingsAsync() + } + } + + private fun setTheme(value: String) { + skipSaveSettings = true + generalSettingsManager.setAppTheme(stringToAppTheme(value)) + } + + private fun setMessageComposeTheme(subThemeString: String) { + skipSaveSettings = true + generalSettingsManager.setMessageComposeTheme(stringToSubTheme(subThemeString)) + } + + private fun setMessageViewTheme(subThemeString: String) { + skipSaveSettings = true + generalSettingsManager.setMessageViewTheme(stringToSubTheme(subThemeString)) } - private fun setTheme(value: String?) { - K9.appTheme = stringToAppTheme(value) - themeManager.updateAppTheme() + private fun setFixedMessageViewTheme(fixedMessageViewTheme: Boolean) { + skipSaveSettings = true + generalSettingsManager.setFixedMessageViewTheme(fixedMessageViewTheme) } private fun appThemeToString(theme: AppTheme) = when (theme) { 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/MessageCryptoDisplayStatus.java b/app/ui/legacy/src/main/java/com/fsck/k9/view/MessageCryptoDisplayStatus.java deleted file mode 100644 index 24c4d8a1f039b174045ba162f69e223df953402b..0000000000000000000000000000000000000000 --- a/app/ui/legacy/src/main/java/com/fsck/k9/view/MessageCryptoDisplayStatus.java +++ /dev/null @@ -1,415 +0,0 @@ -package com.fsck.k9.view; - - -import androidx.annotation.AttrRes; -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.StringRes; - -import com.fsck.k9.ui.R; -import com.fsck.k9.mailstore.CryptoResultAnnotation; -import org.openintents.openpgp.OpenPgpDecryptionResult; -import org.openintents.openpgp.OpenPgpSignatureResult; - - -public enum MessageCryptoDisplayStatus { - LOADING ( - false, - R.attr.openpgp_grey, - R.drawable.status_lock_disabled - ), - - CANCELLED ( - R.attr.openpgp_black, - R.drawable.status_lock_unknown, - R.string.crypto_msg_title_encrypted_unknown, - R.string.crypto_msg_cancelled - ), - - DISABLED ( - false, - R.attr.openpgp_grey, - R.drawable.status_lock_disabled, - R.string.crypto_msg_title_plaintext, - null - ), - UNENCRYPTED_SIGN_ERROR ( - R.attr.openpgp_grey, - R.drawable.status_signature_unknown, - R.string.crypto_msg_title_plaintext, - R.string.crypto_msg_unencrypted_sign_error - ), - INCOMPLETE_SIGNED ( - R.attr.openpgp_black, - R.drawable.status_signature_unknown, - R.string.crypto_msg_title_plaintext, - R.string.crypto_msg_incomplete_signed - ), - - UNENCRYPTED_SIGN_VERIFIED ( - R.attr.openpgp_blue, - R.drawable.status_signature_dots_3, - R.string.crypto_msg_title_unencrypted_signed_e2e, - R.string.crypto_msg_unencrypted_sign_verified - ), - UNENCRYPTED_SIGN_UNVERIFIED ( - R.attr.openpgp_blue, - R.drawable.status_signature, - R.string.crypto_msg_title_unencrypted_signed_e2e, - null - ), - - UNENCRYPTED_SIGN_UNKNOWN ( - R.attr.openpgp_orange, - R.drawable.status_signature_unknown, - R.string.crypto_msg_title_unencrypted_signed, - R.string.crypto_msg_unencrypted_sign_unknown - ), - UNENCRYPTED_SIGN_MISMATCH ( - R.attr.openpgp_grey, - R.drawable.status_signature_unknown, - R.string.crypto_msg_title_unencrypted_signed, - R.string.crypto_msg_unencrypted_sign_mismatch - ), - UNENCRYPTED_SIGN_EXPIRED ( - R.attr.openpgp_grey, - R.drawable.status_signature_unknown, - R.string.crypto_msg_title_unencrypted_signed, - R.string.crypto_msg_unencrypted_sign_expired - ), - UNENCRYPTED_SIGN_REVOKED ( - R.attr.openpgp_grey, - R.drawable.status_signature_unknown, - R.string.crypto_msg_title_unencrypted_signed, - R.string.crypto_msg_unencrypted_sign_revoked - ), - UNENCRYPTED_SIGN_INSECURE ( - R.attr.openpgp_grey, - R.drawable.status_signature_unknown, - R.string.crypto_msg_title_unencrypted_signed, - R.string.crypto_msg_unencrypted_sign_insecure - ), - - ENCRYPTED_SIGN_VERIFIED ( - R.attr.openpgp_green, - R.drawable.status_lock_dots_3, - R.string.crypto_msg_title_encrypted_signed_e2e, - R.string.crypto_msg_encrypted_sign_verified - ), - ENCRYPTED_SIGN_UNVERIFIED ( - R.attr.openpgp_green, - R.drawable.status_lock, - R.string.crypto_msg_title_encrypted_signed_e2e, - null - ), - - ENCRYPTED_SIGN_UNKNOWN ( - R.attr.openpgp_orange, - R.drawable.status_lock_unknown, - R.string.crypto_msg_title_encrypted_signed, - R.string.crypto_msg_encrypted_sign_unknown - ), - ENCRYPTED_SIGN_MISMATCH ( - R.attr.openpgp_grey, - R.drawable.status_lock_error, - R.string.crypto_msg_title_encrypted_signed, - R.string.crypto_msg_encrypted_sign_mismatch - ), - ENCRYPTED_SIGN_EXPIRED ( - R.attr.openpgp_grey, - R.drawable.status_lock_error, - R.string.crypto_msg_title_encrypted_signed, - R.string.crypto_msg_encrypted_sign_expired - ), - ENCRYPTED_SIGN_REVOKED ( - R.attr.openpgp_grey, - R.drawable.status_lock_error, - R.string.crypto_msg_title_encrypted_signed, - R.string.crypto_msg_encrypted_sign_revoked - ), - ENCRYPTED_SIGN_INSECURE ( - R.attr.openpgp_grey, - R.drawable.status_lock_error, - R.string.crypto_msg_title_encrypted_signed, - R.string.crypto_msg_encrypted_sign_insecure - ), - ENCRYPTED_SIGN_ERROR ( - R.attr.openpgp_grey, - R.drawable.status_lock_error, - R.string.crypto_msg_title_encrypted_signed, - R.string.crypto_msg_encrypted_sign_error - ), - ENCRYPTED_INSECURE ( - R.attr.openpgp_red, - R.drawable.status_lock_error, - R.string.crypto_msg_title_encrypted_signed, - R.string.crypto_msg_encrypted_insecure - ), - - ENCRYPTED_UNSIGNED ( - R.attr.openpgp_grey, - R.drawable.status_lock_error, - R.string.crypto_msg_title_encrypted_unsigned, - R.string.crypto_msg_encrypted_unsigned - ), - - ENCRYPTED_ERROR ( - R.attr.openpgp_red, - R.drawable.status_lock_error, - R.string.crypto_msg_title_encrypted_unknown, - R.string.crypto_msg_encrypted_error - ), - - INCOMPLETE_ENCRYPTED ( - R.attr.openpgp_black, - R.drawable.status_lock_unknown, - R.string.crypto_msg_title_encrypted_unknown, - R.string.crypto_msg_encrypted_incomplete - ), - - ENCRYPTED_NO_PROVIDER ( - R.attr.openpgp_red, - R.drawable.status_lock_error, - R.string.crypto_msg_title_encrypted_unknown, - R.string.crypto_msg_encrypted_no_provider - ), - - UNSUPPORTED_ENCRYPTED ( - R.attr.openpgp_red, - R.drawable.status_lock_error, - R.string.crypto_msg_title_encrypted_unknown, - R.string.crypto_msg_unsupported_encrypted - ), - UNSUPPORTED_SIGNED ( - R.attr.openpgp_grey, - R.drawable.status_lock_disabled, - R.string.crypto_msg_title_encrypted_unknown, - R.string.crypto_msg_unsupported_signed - ), - ; - - @AttrRes public final int colorAttr; - @DrawableRes public final int statusIconRes; - @StringRes public final Integer titleTextRes; - @StringRes public final Integer descriptionTextRes; - public boolean isEnabled; - - MessageCryptoDisplayStatus(@AttrRes int colorAttr, @DrawableRes int statusIconRes, @StringRes int titleTextRes, - Integer descriptionTextRes) { - this.colorAttr = colorAttr; - this.statusIconRes = statusIconRes; - - this.titleTextRes = titleTextRes; - this.descriptionTextRes = descriptionTextRes; - } - - MessageCryptoDisplayStatus(boolean isEnabled, @AttrRes int colorAttr, @DrawableRes int statusIconRes, - @StringRes int titleTextRes, Integer descriptionTextRes) { - this(colorAttr, statusIconRes, titleTextRes, descriptionTextRes); - this.isEnabled = isEnabled; - } - - MessageCryptoDisplayStatus(boolean isEnabled, @AttrRes int colorAttr, @DrawableRes int statusIconRes) { - this.colorAttr = colorAttr; - this.statusIconRes = statusIconRes; - - this.titleTextRes = null; - this.descriptionTextRes = null; - - this.isEnabled = isEnabled; - } - - @NonNull - public static MessageCryptoDisplayStatus fromResultAnnotation(CryptoResultAnnotation cryptoResult) { - if (cryptoResult == null) { - return DISABLED; - } - - switch (cryptoResult.getErrorType()) { - case OPENPGP_OK: - return getDisplayStatusForPgpResult(cryptoResult); - - case OPENPGP_ENCRYPTED_BUT_INCOMPLETE: - return INCOMPLETE_ENCRYPTED; - - case OPENPGP_SIGNED_BUT_INCOMPLETE: - return INCOMPLETE_SIGNED; - - case ENCRYPTED_BUT_UNSUPPORTED: - return UNSUPPORTED_ENCRYPTED; - - case SIGNED_BUT_UNSUPPORTED: - return UNSUPPORTED_SIGNED; - - case OPENPGP_UI_CANCELED: - return CANCELLED; - - case OPENPGP_SIGNED_API_ERROR: - return UNENCRYPTED_SIGN_ERROR; - - case OPENPGP_ENCRYPTED_API_ERROR: - return ENCRYPTED_ERROR; - - case OPENPGP_ENCRYPTED_NO_PROVIDER: - return ENCRYPTED_NO_PROVIDER; - } - throw new IllegalStateException("Unhandled case!"); - } - - @NonNull - private static MessageCryptoDisplayStatus getDisplayStatusForPgpResult(CryptoResultAnnotation cryptoResult) { - OpenPgpSignatureResult signatureResult = cryptoResult.getOpenPgpSignatureResult(); - OpenPgpDecryptionResult decryptionResult = cryptoResult.getOpenPgpDecryptionResult(); - if (decryptionResult == null || signatureResult == null) { - throw new AssertionError("Both OpenPGP results must be non-null at this point!"); - } - - if (signatureResult.getResult() == OpenPgpSignatureResult.RESULT_NO_SIGNATURE && - cryptoResult.hasEncapsulatedResult()) { - CryptoResultAnnotation encapsulatedResult = cryptoResult.getEncapsulatedResult(); - if (encapsulatedResult.isOpenPgpResult()) { - signatureResult = encapsulatedResult.getOpenPgpSignatureResult(); - if (signatureResult == null) { - throw new AssertionError("OpenPGP must contain signature result at this point!"); - } - } - } - - switch (decryptionResult.getResult()) { - case OpenPgpDecryptionResult.RESULT_NOT_ENCRYPTED: - return getStatusForPgpUnencryptedResult(signatureResult); - - case OpenPgpDecryptionResult.RESULT_ENCRYPTED: - return getStatusForPgpEncryptedResult(signatureResult); - - case OpenPgpDecryptionResult.RESULT_INSECURE: - return ENCRYPTED_INSECURE; - } - - throw new AssertionError("all cases must be handled, this is a bug!"); - } - - @NonNull - private static MessageCryptoDisplayStatus getStatusForPgpEncryptedResult(OpenPgpSignatureResult signatureResult) { - switch (signatureResult.getResult()) { - case OpenPgpSignatureResult.RESULT_NO_SIGNATURE: - return ENCRYPTED_UNSIGNED; - - case OpenPgpSignatureResult.RESULT_VALID_KEY_CONFIRMED: - case OpenPgpSignatureResult.RESULT_VALID_KEY_UNCONFIRMED: - switch (signatureResult.getSenderStatusResult()) { - case USER_ID_CONFIRMED: - return ENCRYPTED_SIGN_VERIFIED; - case USER_ID_UNCONFIRMED: - return ENCRYPTED_SIGN_UNVERIFIED; - case USER_ID_MISSING: - return ENCRYPTED_SIGN_MISMATCH; - case UNKNOWN: - return ENCRYPTED_SIGN_UNVERIFIED; - } - throw new IllegalStateException("unhandled encrypted result case!"); - - case OpenPgpSignatureResult.RESULT_KEY_MISSING: - return ENCRYPTED_SIGN_UNKNOWN; - - case OpenPgpSignatureResult.RESULT_INVALID_SIGNATURE: - return ENCRYPTED_SIGN_ERROR; - - case OpenPgpSignatureResult.RESULT_INVALID_KEY_EXPIRED: - return ENCRYPTED_SIGN_EXPIRED; - - case OpenPgpSignatureResult.RESULT_INVALID_KEY_REVOKED: - return ENCRYPTED_SIGN_REVOKED; - - case OpenPgpSignatureResult.RESULT_INVALID_KEY_INSECURE: - return ENCRYPTED_SIGN_INSECURE; - - default: - throw new IllegalStateException("unhandled encrypted result case!"); - } - } - - @NonNull - private static MessageCryptoDisplayStatus getStatusForPgpUnencryptedResult(OpenPgpSignatureResult signatureResult) { - switch (signatureResult.getResult()) { - case OpenPgpSignatureResult.RESULT_NO_SIGNATURE: - return DISABLED; - - case OpenPgpSignatureResult.RESULT_VALID_KEY_CONFIRMED: - case OpenPgpSignatureResult.RESULT_VALID_KEY_UNCONFIRMED: - switch (signatureResult.getSenderStatusResult()) { - case USER_ID_CONFIRMED: - return UNENCRYPTED_SIGN_VERIFIED; - case USER_ID_UNCONFIRMED: - return UNENCRYPTED_SIGN_UNVERIFIED; - case USER_ID_MISSING: - return UNENCRYPTED_SIGN_MISMATCH; - case UNKNOWN: - return UNENCRYPTED_SIGN_UNVERIFIED; - } - throw new IllegalStateException("unhandled encrypted result case!"); - - case OpenPgpSignatureResult.RESULT_KEY_MISSING: - return UNENCRYPTED_SIGN_UNKNOWN; - - case OpenPgpSignatureResult.RESULT_INVALID_SIGNATURE: - return UNENCRYPTED_SIGN_ERROR; - - case OpenPgpSignatureResult.RESULT_INVALID_KEY_EXPIRED: - return UNENCRYPTED_SIGN_EXPIRED; - - case OpenPgpSignatureResult.RESULT_INVALID_KEY_REVOKED: - return UNENCRYPTED_SIGN_REVOKED; - - case OpenPgpSignatureResult.RESULT_INVALID_KEY_INSECURE: - return UNENCRYPTED_SIGN_INSECURE; - - default: - throw new IllegalStateException("unhandled encrypted result case!"); - } - } - - public boolean hasAssociatedKey() { - switch (this) { - case ENCRYPTED_SIGN_VERIFIED: - case ENCRYPTED_SIGN_UNVERIFIED: - case ENCRYPTED_SIGN_MISMATCH: - case ENCRYPTED_SIGN_EXPIRED: - case ENCRYPTED_SIGN_REVOKED: - case ENCRYPTED_SIGN_INSECURE: - - case UNENCRYPTED_SIGN_VERIFIED: - case UNENCRYPTED_SIGN_UNVERIFIED: - case UNENCRYPTED_SIGN_MISMATCH: - case UNENCRYPTED_SIGN_EXPIRED: - case UNENCRYPTED_SIGN_REVOKED: - case UNENCRYPTED_SIGN_INSECURE: - return true; - } - return false; - } - - public boolean isUnencryptedSigned() { - switch (this) { - case UNENCRYPTED_SIGN_ERROR: - case UNENCRYPTED_SIGN_UNKNOWN: - case UNENCRYPTED_SIGN_VERIFIED: - case UNENCRYPTED_SIGN_UNVERIFIED: - case UNENCRYPTED_SIGN_MISMATCH: - case UNENCRYPTED_SIGN_EXPIRED: - case UNENCRYPTED_SIGN_REVOKED: - case UNENCRYPTED_SIGN_INSECURE: - return true; - } - return false; - } - - public boolean isUnknownKey() { - switch (this) { - case ENCRYPTED_SIGN_UNKNOWN: - case UNENCRYPTED_SIGN_UNKNOWN: - return true; - } - return false; - } -} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/view/MessageCryptoDisplayStatus.kt b/app/ui/legacy/src/main/java/com/fsck/k9/view/MessageCryptoDisplayStatus.kt new file mode 100644 index 0000000000000000000000000000000000000000..4e157fd63c58f8a4541059c18677b1220d960522 --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/view/MessageCryptoDisplayStatus.kt @@ -0,0 +1,325 @@ +package com.fsck.k9.view + +import androidx.annotation.AttrRes +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import com.fsck.k9.mailstore.CryptoResultAnnotation +import com.fsck.k9.mailstore.CryptoResultAnnotation.CryptoError +import com.fsck.k9.ui.R +import org.openintents.openpgp.OpenPgpDecryptionResult.RESULT_ENCRYPTED +import org.openintents.openpgp.OpenPgpDecryptionResult.RESULT_INSECURE +import org.openintents.openpgp.OpenPgpDecryptionResult.RESULT_NOT_ENCRYPTED +import org.openintents.openpgp.OpenPgpSignatureResult +import org.openintents.openpgp.OpenPgpSignatureResult.RESULT_INVALID_KEY_EXPIRED +import org.openintents.openpgp.OpenPgpSignatureResult.RESULT_INVALID_KEY_INSECURE +import org.openintents.openpgp.OpenPgpSignatureResult.RESULT_INVALID_KEY_REVOKED +import org.openintents.openpgp.OpenPgpSignatureResult.RESULT_INVALID_SIGNATURE +import org.openintents.openpgp.OpenPgpSignatureResult.RESULT_KEY_MISSING +import org.openintents.openpgp.OpenPgpSignatureResult.RESULT_NO_SIGNATURE +import org.openintents.openpgp.OpenPgpSignatureResult.RESULT_VALID_KEY_CONFIRMED +import org.openintents.openpgp.OpenPgpSignatureResult.RESULT_VALID_KEY_UNCONFIRMED +import org.openintents.openpgp.OpenPgpSignatureResult.SenderStatusResult.UNKNOWN +import org.openintents.openpgp.OpenPgpSignatureResult.SenderStatusResult.USER_ID_CONFIRMED +import org.openintents.openpgp.OpenPgpSignatureResult.SenderStatusResult.USER_ID_MISSING +import org.openintents.openpgp.OpenPgpSignatureResult.SenderStatusResult.USER_ID_UNCONFIRMED + +enum class MessageCryptoDisplayStatus( + val isEnabled: Boolean = true, + + @AttrRes + val colorAttr: Int, + + @DrawableRes + val statusIconRes: Int, + + @StringRes + val titleTextRes: Int? = null, + + @StringRes + val descriptionTextRes: Int? = null +) { + LOADING( + isEnabled = false, + colorAttr = R.attr.openpgp_grey, + statusIconRes = R.drawable.status_lock_disabled + ), + CANCELLED( + colorAttr = R.attr.openpgp_black, + statusIconRes = R.drawable.status_lock_unknown, + titleTextRes = R.string.crypto_msg_title_encrypted_unknown, + descriptionTextRes = R.string.crypto_msg_cancelled + ), + DISABLED( + isEnabled = false, + colorAttr = R.attr.openpgp_grey, + statusIconRes = R.drawable.status_lock_disabled, + titleTextRes = R.string.crypto_msg_title_plaintext + ), + UNENCRYPTED_SIGN_ERROR( + colorAttr = R.attr.openpgp_grey, + statusIconRes = R.drawable.status_signature_unknown, + titleTextRes = R.string.crypto_msg_title_plaintext, + descriptionTextRes = R.string.crypto_msg_unencrypted_sign_error + ), + INCOMPLETE_SIGNED( + colorAttr = R.attr.openpgp_black, + statusIconRes = R.drawable.status_signature_unknown, + titleTextRes = R.string.crypto_msg_title_plaintext, + descriptionTextRes = R.string.crypto_msg_incomplete_signed + ), + UNENCRYPTED_SIGN_VERIFIED( + colorAttr = R.attr.openpgp_blue, + statusIconRes = R.drawable.status_signature_dots_3, + titleTextRes = R.string.crypto_msg_title_unencrypted_signed_e2e, + descriptionTextRes = R.string.crypto_msg_unencrypted_sign_verified + ), + UNENCRYPTED_SIGN_UNVERIFIED( + colorAttr = R.attr.openpgp_blue, + statusIconRes = R.drawable.status_signature, + titleTextRes = R.string.crypto_msg_title_unencrypted_signed_e2e + ), + UNENCRYPTED_SIGN_UNKNOWN( + colorAttr = R.attr.openpgp_orange, + statusIconRes = R.drawable.status_signature_unknown, + titleTextRes = R.string.crypto_msg_title_unencrypted_signed, + descriptionTextRes = R.string.crypto_msg_unencrypted_sign_unknown + ), + UNENCRYPTED_SIGN_MISMATCH( + colorAttr = R.attr.openpgp_grey, + statusIconRes = R.drawable.status_signature_unknown, + titleTextRes = R.string.crypto_msg_title_unencrypted_signed, + descriptionTextRes = R.string.crypto_msg_unencrypted_sign_mismatch + ), + UNENCRYPTED_SIGN_EXPIRED( + colorAttr = R.attr.openpgp_grey, + statusIconRes = R.drawable.status_signature_unknown, + titleTextRes = R.string.crypto_msg_title_unencrypted_signed, + descriptionTextRes = R.string.crypto_msg_unencrypted_sign_expired + ), + UNENCRYPTED_SIGN_REVOKED( + colorAttr = R.attr.openpgp_grey, + statusIconRes = R.drawable.status_signature_unknown, + titleTextRes = R.string.crypto_msg_title_unencrypted_signed, + descriptionTextRes = R.string.crypto_msg_unencrypted_sign_revoked + ), + UNENCRYPTED_SIGN_INSECURE( + colorAttr = R.attr.openpgp_grey, + statusIconRes = R.drawable.status_signature_unknown, + titleTextRes = R.string.crypto_msg_title_unencrypted_signed, + descriptionTextRes = R.string.crypto_msg_unencrypted_sign_insecure + ), + ENCRYPTED_SIGN_VERIFIED( + colorAttr = R.attr.openpgp_green, + statusIconRes = R.drawable.status_lock_dots_3, + titleTextRes = R.string.crypto_msg_title_encrypted_signed_e2e, + descriptionTextRes = R.string.crypto_msg_encrypted_sign_verified + ), + ENCRYPTED_SIGN_UNVERIFIED( + colorAttr = R.attr.openpgp_green, + statusIconRes = R.drawable.status_lock, + titleTextRes = R.string.crypto_msg_title_encrypted_signed_e2e + ), + ENCRYPTED_SIGN_UNKNOWN( + colorAttr = R.attr.openpgp_orange, + statusIconRes = R.drawable.status_lock_unknown, + titleTextRes = R.string.crypto_msg_title_encrypted_signed, + descriptionTextRes = R.string.crypto_msg_encrypted_sign_unknown + ), + ENCRYPTED_SIGN_MISMATCH( + colorAttr = R.attr.openpgp_grey, + statusIconRes = R.drawable.status_lock_error, + titleTextRes = R.string.crypto_msg_title_encrypted_signed, + descriptionTextRes = R.string.crypto_msg_encrypted_sign_mismatch + ), + ENCRYPTED_SIGN_EXPIRED( + colorAttr = R.attr.openpgp_grey, + statusIconRes = R.drawable.status_lock_error, + titleTextRes = R.string.crypto_msg_title_encrypted_signed, + descriptionTextRes = R.string.crypto_msg_encrypted_sign_expired + ), + ENCRYPTED_SIGN_REVOKED( + colorAttr = R.attr.openpgp_grey, + statusIconRes = R.drawable.status_lock_error, + titleTextRes = R.string.crypto_msg_title_encrypted_signed, + descriptionTextRes = R.string.crypto_msg_encrypted_sign_revoked + ), + ENCRYPTED_SIGN_INSECURE( + colorAttr = R.attr.openpgp_grey, + statusIconRes = R.drawable.status_lock_error, + titleTextRes = R.string.crypto_msg_title_encrypted_signed, + descriptionTextRes = R.string.crypto_msg_encrypted_sign_insecure + ), + ENCRYPTED_SIGN_ERROR( + colorAttr = R.attr.openpgp_grey, + statusIconRes = R.drawable.status_lock_error, + titleTextRes = R.string.crypto_msg_title_encrypted_signed, + descriptionTextRes = R.string.crypto_msg_encrypted_sign_error + ), + ENCRYPTED_INSECURE( + colorAttr = R.attr.openpgp_red, + statusIconRes = R.drawable.status_lock_error, + titleTextRes = R.string.crypto_msg_title_encrypted_signed, + descriptionTextRes = R.string.crypto_msg_encrypted_insecure + ), + ENCRYPTED_UNSIGNED( + colorAttr = R.attr.openpgp_grey, + statusIconRes = R.drawable.status_lock_error, + titleTextRes = R.string.crypto_msg_title_encrypted_unsigned, + descriptionTextRes = R.string.crypto_msg_encrypted_unsigned + ), + ENCRYPTED_ERROR( + colorAttr = R.attr.openpgp_red, + statusIconRes = R.drawable.status_lock_error, + titleTextRes = R.string.crypto_msg_title_encrypted_unknown, + descriptionTextRes = R.string.crypto_msg_encrypted_error + ), + INCOMPLETE_ENCRYPTED( + colorAttr = R.attr.openpgp_black, + statusIconRes = R.drawable.status_lock_unknown, + titleTextRes = R.string.crypto_msg_title_encrypted_unknown, + descriptionTextRes = R.string.crypto_msg_encrypted_incomplete + ), + ENCRYPTED_NO_PROVIDER( + colorAttr = R.attr.openpgp_red, + statusIconRes = R.drawable.status_lock_error, + titleTextRes = R.string.crypto_msg_title_encrypted_unknown, + descriptionTextRes = R.string.crypto_msg_encrypted_no_provider + ), + UNSUPPORTED_ENCRYPTED( + colorAttr = R.attr.openpgp_red, + statusIconRes = R.drawable.status_lock_error, + titleTextRes = R.string.crypto_msg_title_encrypted_unknown, + descriptionTextRes = R.string.crypto_msg_unsupported_encrypted + ), + UNSUPPORTED_SIGNED( + colorAttr = R.attr.openpgp_grey, + statusIconRes = R.drawable.status_lock_disabled, + titleTextRes = R.string.crypto_msg_title_encrypted_unknown, + descriptionTextRes = R.string.crypto_msg_unsupported_signed + ); + + fun hasAssociatedKey(): Boolean { + return when (this) { + ENCRYPTED_SIGN_VERIFIED, + ENCRYPTED_SIGN_UNVERIFIED, + ENCRYPTED_SIGN_MISMATCH, + ENCRYPTED_SIGN_EXPIRED, + ENCRYPTED_SIGN_REVOKED, + ENCRYPTED_SIGN_INSECURE, + UNENCRYPTED_SIGN_VERIFIED, + UNENCRYPTED_SIGN_UNVERIFIED, + UNENCRYPTED_SIGN_MISMATCH, + UNENCRYPTED_SIGN_EXPIRED, + UNENCRYPTED_SIGN_REVOKED, + UNENCRYPTED_SIGN_INSECURE -> true + else -> false + } + } + + val isUnencryptedSigned: Boolean + get() = when (this) { + UNENCRYPTED_SIGN_ERROR, + UNENCRYPTED_SIGN_UNKNOWN, + UNENCRYPTED_SIGN_VERIFIED, + UNENCRYPTED_SIGN_UNVERIFIED, + UNENCRYPTED_SIGN_MISMATCH, + UNENCRYPTED_SIGN_EXPIRED, + UNENCRYPTED_SIGN_REVOKED, + UNENCRYPTED_SIGN_INSECURE -> true + else -> false + } + + val isUnknownKey: Boolean + get() = when (this) { + ENCRYPTED_SIGN_UNKNOWN, UNENCRYPTED_SIGN_UNKNOWN -> true + else -> false + } + + companion object { + @JvmStatic + fun fromResultAnnotation(cryptoResult: CryptoResultAnnotation?): MessageCryptoDisplayStatus { + return when (cryptoResult?.errorType) { + null -> DISABLED + CryptoError.OPENPGP_OK -> getDisplayStatusForPgpResult(cryptoResult) + CryptoError.OPENPGP_ENCRYPTED_BUT_INCOMPLETE -> INCOMPLETE_ENCRYPTED + CryptoError.OPENPGP_SIGNED_BUT_INCOMPLETE -> INCOMPLETE_SIGNED + CryptoError.ENCRYPTED_BUT_UNSUPPORTED -> UNSUPPORTED_ENCRYPTED + CryptoError.SIGNED_BUT_UNSUPPORTED -> UNSUPPORTED_SIGNED + CryptoError.OPENPGP_UI_CANCELED -> CANCELLED + CryptoError.OPENPGP_SIGNED_API_ERROR -> UNENCRYPTED_SIGN_ERROR + CryptoError.OPENPGP_ENCRYPTED_API_ERROR -> ENCRYPTED_ERROR + CryptoError.OPENPGP_ENCRYPTED_NO_PROVIDER -> ENCRYPTED_NO_PROVIDER + else -> error("Unhandled case!") + } + } + + private fun getDisplayStatusForPgpResult(cryptoResult: CryptoResultAnnotation): MessageCryptoDisplayStatus { + var signatureResult = cryptoResult.openPgpSignatureResult + val decryptionResult = cryptoResult.openPgpDecryptionResult + if (decryptionResult == null || signatureResult == null) { + throw AssertionError("Both OpenPGP results must be non-null at this point!") + } + + if (signatureResult.result == RESULT_NO_SIGNATURE && cryptoResult.hasEncapsulatedResult()) { + val encapsulatedResult = cryptoResult.encapsulatedResult + if (encapsulatedResult.isOpenPgpResult) { + signatureResult = encapsulatedResult.openPgpSignatureResult + ?: throw AssertionError("OpenPGP must contain signature result at this point!") + } + } + + return when (decryptionResult.getResult()) { + RESULT_NOT_ENCRYPTED -> getStatusForPgpUnencryptedResult(signatureResult) + RESULT_ENCRYPTED -> getStatusForPgpEncryptedResult(signatureResult) + RESULT_INSECURE -> ENCRYPTED_INSECURE + else -> throw AssertionError("all cases must be handled, this is a bug!") + } + } + + private fun getStatusForPgpEncryptedResult( + signatureResult: OpenPgpSignatureResult + ): MessageCryptoDisplayStatus { + return when (signatureResult.result) { + RESULT_NO_SIGNATURE -> ENCRYPTED_UNSIGNED + RESULT_VALID_KEY_CONFIRMED, RESULT_VALID_KEY_UNCONFIRMED -> { + return when (signatureResult.senderStatusResult) { + USER_ID_CONFIRMED -> ENCRYPTED_SIGN_VERIFIED + USER_ID_UNCONFIRMED -> ENCRYPTED_SIGN_UNVERIFIED + USER_ID_MISSING -> ENCRYPTED_SIGN_MISMATCH + UNKNOWN -> ENCRYPTED_SIGN_UNVERIFIED + else -> error("unhandled encrypted result case!") + } + } + RESULT_KEY_MISSING -> ENCRYPTED_SIGN_UNKNOWN + RESULT_INVALID_SIGNATURE -> ENCRYPTED_SIGN_ERROR + RESULT_INVALID_KEY_EXPIRED -> ENCRYPTED_SIGN_EXPIRED + RESULT_INVALID_KEY_REVOKED -> ENCRYPTED_SIGN_REVOKED + RESULT_INVALID_KEY_INSECURE -> ENCRYPTED_SIGN_INSECURE + else -> error("unhandled encrypted result case!") + } + } + + private fun getStatusForPgpUnencryptedResult( + signatureResult: OpenPgpSignatureResult + ): MessageCryptoDisplayStatus { + return when (signatureResult.result) { + RESULT_NO_SIGNATURE -> DISABLED + RESULT_VALID_KEY_CONFIRMED, RESULT_VALID_KEY_UNCONFIRMED -> { + return when (signatureResult.senderStatusResult) { + USER_ID_CONFIRMED -> UNENCRYPTED_SIGN_VERIFIED + USER_ID_UNCONFIRMED -> UNENCRYPTED_SIGN_UNVERIFIED + USER_ID_MISSING -> UNENCRYPTED_SIGN_MISMATCH + UNKNOWN -> UNENCRYPTED_SIGN_UNVERIFIED + else -> error("unhandled encrypted result case!") + } + } + RESULT_KEY_MISSING -> UNENCRYPTED_SIGN_UNKNOWN + RESULT_INVALID_SIGNATURE -> UNENCRYPTED_SIGN_ERROR + RESULT_INVALID_KEY_EXPIRED -> UNENCRYPTED_SIGN_EXPIRED + RESULT_INVALID_KEY_REVOKED -> UNENCRYPTED_SIGN_REVOKED + RESULT_INVALID_KEY_INSECURE -> UNENCRYPTED_SIGN_INSECURE + else -> error("unhandled encrypted result case!") + } + } + } +} 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 e265cd00f1d0fd3673ee88d3c1d84c37ed96a29a..47c384edfa088e69097b854ceb116f8254ec6ef4 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,22 +2,11 @@ package com.fsck.k9.view; import java.util.Arrays; -import java.util.List; 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; @@ -27,6 +16,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; @@ -38,9 +30,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; @@ -328,10 +318,10 @@ public class MessageHeader extends LinearLayout implements OnClickListener, OnLo } private void setCryptoDisplayStatus(MessageCryptoDisplayStatus displayStatus) { - int color = ThemeUtils.getStyledColor(getContext(), displayStatus.colorAttr); - mCryptoStatusIcon.setEnabled(displayStatus.isEnabled); + int color = ThemeUtils.getStyledColor(getContext(), displayStatus.getColorAttr()); + mCryptoStatusIcon.setEnabled(displayStatus.isEnabled()); mCryptoStatusIcon.setVisibility(View.VISIBLE); - mCryptoStatusIcon.setImageResource(displayStatus.statusIconRes); + mCryptoStatusIcon.setImageResource(displayStatus.getStatusIconRes()); mCryptoStatusIcon.setColorFilter(color); } 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/ic_description.xml b/app/ui/legacy/src/main/res/drawable/ic_description.xml index 7887fe11f5c0ac7cfd9463fd555831aa39b1fe1c..361aebd47506253f4025ad65ab7b24960438cdcb 100644 --- a/app/ui/legacy/src/main/res/drawable/ic_description.xml +++ b/app/ui/legacy/src/main/res/drawable/ic_description.xml @@ -6,5 +6,5 @@ android:viewportHeight="24"> + android:pathData="M14 2H6c-1.1 0-1.99 0.9-1.99 2L4 20c0 1.1 0.89 2 1.99 2H18c1.1 0 2-0.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z" /> diff --git a/app/ui/legacy/src/main/res/drawable/ic_download.xml b/app/ui/legacy/src/main/res/drawable/ic_download.xml new file mode 100644 index 0000000000000000000000000000000000000000..0964bd62dbdbbc9b97dc3070c88e04e3b5ca1052 --- /dev/null +++ b/app/ui/legacy/src/main/res/drawable/ic_download.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/ui/legacy/src/main/res/drawable/ic_folder_manage.xml b/app/ui/legacy/src/main/res/drawable/ic_folder_manage.xml new file mode 100644 index 0000000000000000000000000000000000000000..5a836825b39488f5e4be0b8364029ae230dbf051 --- /dev/null +++ b/app/ui/legacy/src/main/res/drawable/ic_folder_manage.xml @@ -0,0 +1,10 @@ + + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..de26f783aac2336fe6ea1373161490e29ac770a3 --- /dev/null +++ b/app/ui/legacy/src/main/res/drawable/ic_mark_new.xml @@ -0,0 +1,10 @@ + + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..f0721d991595c492169dbd00ccce9f656b69320a --- /dev/null +++ b/app/ui/legacy/src/main/res/drawable/ic_refresh_all.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/ui/legacy/src/main/res/drawable/ic_star.xml b/app/ui/legacy/src/main/res/drawable/ic_star.xml index 633516533ff1910feeee2de30c64ed279035640d..9b3f2e1ae40f4ca2a97ed544d014e6f5b9f26240 100644 --- a/app/ui/legacy/src/main/res/drawable/ic_star.xml +++ b/app/ui/legacy/src/main/res/drawable/ic_star.xml @@ -1,7 +1,7 @@ + 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:paddingStart="8dp" + android:paddingEnd="0dp"> + android:paddingStart="24dp" + android:paddingEnd="16dp"> + 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 7e96929b86ff7cd4ce758c6c4e00abc4d42b5292..38bb5202d690ef60ba020e1850f9d1f722eb925c 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 @@ -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.AccountSetupIncoming"> @@ -97,7 +98,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="@dimen/account_setup_margin_between_items_incoming_and_outgoing" - app:passwordToggleEnabled="true"> + app:endIconMode="password_toggle"> 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 fda26fcc03c9e64f127d52952a7ab7762b0dfb69..cd920297f4faf98736199828901d8155517beb19 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"> @@ -59,7 +60,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="number" - android:hint="@string/account_setup_incoming_port_label" + android:hint="@string/account_setup_outgoing_port_label" android:singleLine="true" /> @@ -111,7 +112,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="@dimen/account_setup_margin_between_items_incoming_and_outgoing" - app:passwordToggleEnabled="true"> + app:endIconMode="password_toggle"> diff --git a/app/ui/legacy/src/main/res/layout/account_spinner_item.xml b/app/ui/legacy/src/main/res/layout/account_spinner_item.xml index cedc27fedb8c59462a0d76bcc879e6929e60ec86..0f40688bf071ca720ac1f6966926c82d3eede005 100644 --- a/app/ui/legacy/src/main/res/layout/account_spinner_item.xml +++ b/app/ui/legacy/src/main/res/layout/account_spinner_item.xml @@ -5,7 +5,8 @@ android:layout_height="match_parent" android:gravity="center_vertical" android:orientation="vertical" - android:paddingRight="16dp"> + android:paddingStart="0dp" + android:paddingEnd="16dp"> + android:layout_width="8dp" + android:layout_marginEnd="8dp" + tools:background="@sample/accounts.json/data/color" + /> + android:paddingStart="1dp" + android:paddingEnd="0dp"> + android:textAppearance="@style/TextAppearance.K9.Medium" + tools:text="@sample/accounts.json/data/name"/> + 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_onboarding.xml b/app/ui/legacy/src/main/res/layout/activity_onboarding.xml new file mode 100644 index 0000000000000000000000000000000000000000..fc665332bda0bee122ed67e90ee8dc36fa89276f --- /dev/null +++ b/app/ui/legacy/src/main/res/layout/activity_onboarding.xml @@ -0,0 +1,22 @@ + + + + + + + + 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/changelog_list_change_item.xml b/app/ui/legacy/src/main/res/layout/changelog_list_change_item.xml index 8979da9b17247d3eba7099482e976a081aab5077..2c852e330a1b3819e9b22722316c0e0ea6b6673a 100644 --- a/app/ui/legacy/src/main/res/layout/changelog_list_change_item.xml +++ b/app/ui/legacy/src/main/res/layout/changelog_list_change_item.xml @@ -22,6 +22,7 @@ android:layout_height="wrap_content" android:layout_weight="1" android:paddingTop="4dp" + android:paddingStart="0dp" android:paddingEnd="16dp" android:paddingBottom="4dp" android:textAppearance="@style/TextAppearance.AppCompat.Body1" 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..5ed88524dc58c011aa7e7b09ab303a88636dc88d 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,15 +1,17 @@ + android:gravity="start|center_vertical"> + 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..aef383d880c274a4d65192fe5f8ba65418452fc3 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,13 +1,14 @@ + android:paddingStart="12dp" + android:paddingEnd="4dp"> + 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">