diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index ef762dd54041e5ca58f8bd17f8d15415243f5a41..9592db5454b05bb4b7435443364635f65b5210a7 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -3,7 +3,7 @@ If the app is not behaving like it should, it's not necessarily a bug. Please consult the following resources before submitting a new issue. -* [User Manual](https://k9mail.app/documentation/) +* [User Manual](https://docs.k9mail.app/) * [Frequently Asked Questions](https://forum.k9mail.app/c/faq) * [Support Forum](https://forum.k9mail.app/) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index eea3360e3268f894b36fb0620bd8a2cf5022af07..5823e398a512e21c3e5fdcc28652586d92bc217e 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1 @@ -liberapay: k9mail -github: cketti +custom: "https://mzla.link/k9-give" diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a746ed32f7cd59898e0963aec13b70253747dbc0..5b59297a1824b6d8652c9a580b5af005926a37cc 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -57,7 +57,7 @@ body: id: expected attributes: label: Expected behavior - description: After following the steps, what did you think K-9 Mail would do? + description: After following the steps, what did you think Mail would do? validations: required: true - type: textarea diff --git a/app/autodiscovery/providersxml/src/main/java/com/fsck/k9/autodiscovery/providersxml/KoinModule.kt b/app/autodiscovery/providersxml/src/main/java/com/fsck/k9/autodiscovery/providersxml/KoinModule.kt index 839e8dc42c0bfd3166a29845ae7f7ef88ee4db0f..11ee30e24f78670e531ea40ff9bfc0fd70364896 100644 --- a/app/autodiscovery/providersxml/src/main/java/com/fsck/k9/autodiscovery/providersxml/KoinModule.kt +++ b/app/autodiscovery/providersxml/src/main/java/com/fsck/k9/autodiscovery/providersxml/KoinModule.kt @@ -4,5 +4,5 @@ import org.koin.dsl.module val autodiscoveryProvidersXmlModule = module { factory { ProvidersXmlProvider(context = get()) } - factory { ProvidersXmlDiscovery(xmlProvider = get()) } + factory { ProvidersXmlDiscovery(xmlProvider = get(), oAuthConfigurationProvider = get()) } } diff --git a/app/autodiscovery/providersxml/src/main/java/com/fsck/k9/autodiscovery/providersxml/ProvidersXmlDiscovery.kt b/app/autodiscovery/providersxml/src/main/java/com/fsck/k9/autodiscovery/providersxml/ProvidersXmlDiscovery.kt index 23764d24103f5164000ad115978e337e863a0b8c..b2dd7bd2194b92bbdb7b2886a09f373a0dbc5292 100644 --- a/app/autodiscovery/providersxml/src/main/java/com/fsck/k9/autodiscovery/providersxml/ProvidersXmlDiscovery.kt +++ b/app/autodiscovery/providersxml/src/main/java/com/fsck/k9/autodiscovery/providersxml/ProvidersXmlDiscovery.kt @@ -9,14 +9,14 @@ import com.fsck.k9.autodiscovery.api.DiscoveryTarget import com.fsck.k9.helper.EmailHelper import com.fsck.k9.mail.AuthType import com.fsck.k9.mail.ConnectionSecurity -import com.fsck.k9.mail.oauth.OAuth2Provider +import com.fsck.k9.oauth.OAuthConfigurationProvider import com.fsck.k9.preferences.Protocols -import java.net.URI import org.xmlpull.v1.XmlPullParser import timber.log.Timber class ProvidersXmlDiscovery( - private val xmlProvider: ProvidersXmlProvider + private val xmlProvider: ProvidersXmlProvider, + private val oAuthConfigurationProvider: OAuthConfigurationProvider ) : ConnectionSettingsDiscovery { override fun discover(email: String, target: DiscoveryTarget): DiscoveryResults? { @@ -92,20 +92,13 @@ class ProvidersXmlDiscovery( val username = incomingUsernameTemplate.fillInUsernameTemplate(email, user, domain) - val xoauth2 = OAuth2Provider.isXOAuth2(domain) - val xoauth2Label = if (xoauth2) AuthType.XOAUTH2.name else "" - val xoauth2Colon = if (xoauth2) ":" else "" - val security = when { incomingUriTemplate.startsWith("imap+ssl") -> ConnectionSecurity.SSL_TLS_REQUIRED incomingUriTemplate.startsWith("imap+tls") -> ConnectionSecurity.STARTTLS_REQUIRED else -> error("Connection security required") } - val incomingUri = with(URI(incomingUriTemplate)) { - URI(scheme, "$xoauth2Label$xoauth2Colon$username", host, port, null, null, null).toString() - } - val uri = Uri.parse(incomingUri) + val uri = Uri.parse(incomingUriTemplate) val host = uri.host ?: error("Host name required") val port = if (uri.port == -1) { if (security == ConnectionSecurity.STARTTLS_REQUIRED) 143 else 993 @@ -113,19 +106,19 @@ class ProvidersXmlDiscovery( uri.port } - val authType = if (xoauth2) AuthType.XOAUTH2 else AuthType.PLAIN + val authType = if (oAuthConfigurationProvider.getConfiguration(host) != null) { + AuthType.XOAUTH2 + } else { + AuthType.PLAIN + } + return DiscoveredServerSettings(Protocols.IMAP, host, port, security, authType, username) } private fun Provider.toOutgoingServerSettings(email: String): DiscoveredServerSettings? { - val user = EmailHelper.getLocalPartFromEmailAddress(email) ?: return null val domain = EmailHelper.getDomainFromEmailAddress(email) ?: return null - val xoauth2 = OAuth2Provider.isXOAuth2(domain) - val xoauth2Label = if (xoauth2) AuthType.XOAUTH2.name else "" - val xoauth2Colon = if (xoauth2) ":" else "" - val username = outgoingUsernameTemplate.fillInUsernameTemplate(email, user, domain) val security = when { @@ -134,11 +127,7 @@ class ProvidersXmlDiscovery( else -> error("Connection security required") } - val outgoingUri = with(URI(outgoingUriTemplate)) { - URI(scheme, "$username$xoauth2Colon$xoauth2Label", host, port, null, null, null).toString() - } - - val uri = Uri.parse(outgoingUri) + val uri = Uri.parse(outgoingUriTemplate) val host = uri.host ?: error("Host name required") val port = if (uri.port == -1) { if (security == ConnectionSecurity.STARTTLS_REQUIRED) 587 else 465 @@ -146,7 +135,12 @@ class ProvidersXmlDiscovery( uri.port } - val authType = if (xoauth2) AuthType.XOAUTH2 else AuthType.PLAIN + val authType = if (oAuthConfigurationProvider.getConfiguration(host) != null) { + AuthType.XOAUTH2 + } else { + AuthType.PLAIN + } + return DiscoveredServerSettings(Protocols.SMTP, host, port, security, authType, username) } diff --git a/app/autodiscovery/providersxml/src/main/res/xml/providers.xml b/app/autodiscovery/providersxml/src/main/res/xml/providers.xml index 48cc76093683ca5912c02f81c9dbf9c61baf3244..2b75500327d5405943efbef906f449db869f8e92 100644 --- a/app/autodiscovery/providersxml/src/main/res/xml/providers.xml +++ b/app/autodiscovery/providersxml/src/main/res/xml/providers.xml @@ -254,10 +254,6 @@ - - - - @@ -717,4 +713,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/autodiscovery/providersxml/src/test/java/com/fsck/k9/autodiscovery/providersxml/ProvidersXmlDiscoveryTest.kt b/app/autodiscovery/providersxml/src/test/java/com/fsck/k9/autodiscovery/providersxml/ProvidersXmlDiscoveryTest.kt index 5f36f322391e4ae21cca418245947e0262e01adc..4e7d3f329e63388c252178e3466f0f30e90619a6 100644 --- a/app/autodiscovery/providersxml/src/test/java/com/fsck/k9/autodiscovery/providersxml/ProvidersXmlDiscoveryTest.kt +++ b/app/autodiscovery/providersxml/src/test/java/com/fsck/k9/autodiscovery/providersxml/ProvidersXmlDiscoveryTest.kt @@ -5,12 +5,15 @@ import com.fsck.k9.RobolectricTest import com.fsck.k9.autodiscovery.api.DiscoveryTarget import com.fsck.k9.mail.AuthType import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.oauth.OAuthConfiguration +import com.fsck.k9.oauth.OAuthConfigurationProvider import com.google.common.truth.Truth.assertThat import org.junit.Test class ProvidersXmlDiscoveryTest : RobolectricTest() { private val xmlProvider = ProvidersXmlProvider(ApplicationProvider.getApplicationContext()) - private val providersXmlDiscovery = ProvidersXmlDiscovery(xmlProvider) + private val oAuthConfigurationProvider = createOAuthConfigurationProvider() + private val providersXmlDiscovery = ProvidersXmlDiscovery(xmlProvider, oAuthConfigurationProvider) @Test fun discover_withGmailDomain_shouldReturnCorrectSettings() { @@ -39,4 +42,21 @@ class ProvidersXmlDiscoveryTest : RobolectricTest() { assertThat(connectionSettings).isNull() } + + private fun createOAuthConfigurationProvider(): OAuthConfigurationProvider { + val googleConfig = OAuthConfiguration( + clientId = "irrelevant", + scopes = listOf("irrelevant"), + authorizationEndpoint = "irrelevant", + tokenEndpoint = "irrelevant", + redirectUri = "irrelevant" + ) + + return OAuthConfigurationProvider( + configurations = mapOf( + listOf("imap.gmail.com", "smtp.gmail.com") to googleConfig, + ), + googleConfiguration = googleConfig + ) + } } diff --git a/app/core/src/main/java/com/fsck/k9/Account.kt b/app/core/src/main/java/com/fsck/k9/Account.kt index c1e66a281915b85e8c0c3651f0037f17cf2a81a5..454007a79d33dd59a4d53ddf069b7ff6480f9f0c 100644 --- a/app/core/src/main/java/com/fsck/k9/Account.kt +++ b/app/core/src/main/java/com/fsck/k9/Account.kt @@ -2,11 +2,9 @@ package com.fsck.k9 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 java.util.Calendar import java.util.Date -import java.util.concurrent.ConcurrentHashMap /** * Account stores all of the settings for a single account defined by the user. Each account is defined by a UUID. @@ -36,6 +34,10 @@ class Account(override val uuid: String) : BaseAccount { internalOutgoingServerSettings = value } + @get:Synchronized + @set:Synchronized + var oAuthState: String? = null + /** * Storage provider ID, used to locate and manage the underlying DB/file storage. */ @@ -225,7 +227,10 @@ class Account(override val uuid: String) : BaseAccount { @set:Synchronized var idleRefreshMinutes = 0 - private val compressionMap: MutableMap = ConcurrentHashMap() + @get:JvmName("useCompression") + @get:Synchronized + @set:Synchronized + var useCompression = true @get:Synchronized @set:Synchronized @@ -500,20 +505,6 @@ class Account(override val uuid: String) : BaseAccount { this.sortAscending[sortType] = sortAscending } - @Synchronized - fun setCompression(networkType: NetworkType, useCompression: Boolean) { - compressionMap[networkType] = useCompression - } - - @Synchronized - fun useCompression(networkType: NetworkType): Boolean { - return compressionMap[networkType] ?: return true - } - - fun getCompressionMap(): Map { - return compressionMap.toMap() - } - @Synchronized fun replaceIdentities(identities: List) { this.identities = identities.toMutableList() 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 7fe5ea654a4ebcd07ae12ba88508ee981e0593b5..810753e7965e823c0c64915d4c1d5d6a46f13ac5 100644 --- a/app/core/src/main/java/com/fsck/k9/AccountPreferenceSerializer.kt +++ b/app/core/src/main/java/com/fsck/k9/AccountPreferenceSerializer.kt @@ -15,7 +15,6 @@ import com.fsck.k9.Account.ShowPictures import com.fsck.k9.Account.SortType import com.fsck.k9.Account.SpecialFolderSelection import com.fsck.k9.helper.Utility -import com.fsck.k9.mail.NetworkType import com.fsck.k9.mailstore.StorageManager import com.fsck.k9.preferences.Storage import com.fsck.k9.preferences.StorageEditor @@ -37,6 +36,7 @@ class AccountPreferenceSerializer( outgoingServerSettings = serverSettingsSerializer.deserialize( storage.getString("$accountUuid.$OUTGOING_SERVER_SETTINGS_KEY", "") ) + oAuthState = storage.getString("$accountUuid.oAuthState", null) localStorageProviderId = storage.getString("$accountUuid.localStorageProvider", storageManager.defaultProviderId) name = storage.getString("$accountUuid.description", null) alwaysBcc = storage.getString("$accountUuid.alwaysBcc", alwaysBcc) @@ -120,10 +120,7 @@ class AccountPreferenceSerializer( isDefaultQuotedTextShown = storage.getBoolean("$accountUuid.defaultQuotedTextShown", DEFAULT_QUOTED_TEXT_SHOWN) isReplyAfterQuote = storage.getBoolean("$accountUuid.replyAfterQuote", DEFAULT_REPLY_AFTER_QUOTE) isStripSignature = storage.getBoolean("$accountUuid.stripSignature", DEFAULT_STRIP_SIGNATURE) - for (type in NetworkType.values()) { - val useCompression = storage.getBoolean("$accountUuid.useCompression.$type", true) - setCompression(type, useCompression) - } + useCompression = storage.getBoolean("$accountUuid.useCompression", true) importedAutoExpandFolder = storage.getString("$accountUuid.autoExpandFolderName", null) @@ -244,6 +241,7 @@ class AccountPreferenceSerializer( with(account) { editor.putString("$accountUuid.$INCOMING_SERVER_SETTINGS_KEY", serverSettingsSerializer.serialize(incomingServerSettings)) editor.putString("$accountUuid.$OUTGOING_SERVER_SETTINGS_KEY", serverSettingsSerializer.serialize(outgoingServerSettings)) + editor.putString("$accountUuid.oAuthState", oAuthState) editor.putString("$accountUuid.localStorageProvider", localStorageProviderId) editor.putString("$accountUuid.description", name) editor.putString("$accountUuid.alwaysBcc", alwaysBcc) @@ -334,13 +332,7 @@ class AccountPreferenceSerializer( editor.putLong("$accountUuid.lastFolderListRefreshTime", lastFolderListRefreshTime) editor.putBoolean("$accountUuid.isFinishedSetup", isFinishedSetup) - val compressionMap = getCompressionMap() - for (type in NetworkType.values()) { - val useCompression = compressionMap[type] - if (useCompression != null) { - editor.putBoolean("$accountUuid.useCompression.$type", useCompression) - } - } + editor.putBoolean("$accountUuid.useCompression", useCompression) } saveIdentities(account, storage, editor) @@ -369,6 +361,7 @@ class AccountPreferenceSerializer( editor.remove("$accountUuid.$INCOMING_SERVER_SETTINGS_KEY") editor.remove("$accountUuid.$OUTGOING_SERVER_SETTINGS_KEY") + editor.remove("$accountUuid.oAuthState") editor.remove("$accountUuid.description") editor.remove("$accountUuid.name") editor.remove("$accountUuid.email") @@ -456,10 +449,8 @@ class AccountPreferenceSerializer( editor.remove("$accountUuid.lastSyncTime") editor.remove("$accountUuid.lastFolderListRefreshTime") editor.remove("$accountUuid.isFinishedSetup") + editor.remove("$accountUuid.useCompression") - for (type in NetworkType.values()) { - editor.remove("$accountUuid.useCompression." + type.name) - } deleteIdentities(account, storage, editor) // TODO: Remove preference settings that may exist for individual folders in the account. } 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 ba824857c362402b076fdeda1cc6a97ddb0e6b5a..b0abc49e9a79211026b1a941703733da9333127d 100644 --- a/app/core/src/main/java/com/fsck/k9/K9.kt +++ b/app/core/src/main/java/com/fsck/k9/K9.kt @@ -301,6 +301,7 @@ object K9 : EarlyInit { override fun debugSensitive(): Boolean = isSensitiveDebugLoggingEnabled }) + com.fsck.k9.logging.Timber.logger = TimberLogger() checkCachedDatabaseVersion(context) 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 05c30287cc1146b04577fcce4ad85c4829eafb72..85cacac5b97cbe63f83790bf0b5a4d6e12fc9bba 100644 --- a/app/core/src/main/java/com/fsck/k9/KoinModule.kt +++ b/app/core/src/main/java/com/fsck/k9/KoinModule.kt @@ -2,7 +2,7 @@ package com.fsck.k9 import android.content.Context import com.fsck.k9.helper.Contacts -import com.fsck.k9.mail.ssl.DefaultTrustedSocketFactory +import com.fsck.k9.helper.DefaultTrustedSocketFactory import com.fsck.k9.mail.ssl.LocalKeyStore import com.fsck.k9.mail.ssl.TrustManagerFactory import com.fsck.k9.mail.ssl.TrustedSocketFactory 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 f7eaab83d92a56e823589467f1bcdea0aeb6e700..eca0f09fb1a7f1621f077ddef1d2eb4a94188ce7 100644 --- a/app/core/src/main/java/com/fsck/k9/Preferences.kt +++ b/app/core/src/main/java/com/fsck/k9/Preferences.kt @@ -201,7 +201,7 @@ class Preferences internal constructor( val defaultAccount: Account? get() = accounts.firstOrNull() - fun saveAccount(account: Account) { + override fun saveAccount(account: Account) { ensureAssignedAccountNumber(account) processChangedValues(account) diff --git a/app/core/src/main/java/com/fsck/k9/TimberLogger.kt b/app/core/src/main/java/com/fsck/k9/TimberLogger.kt new file mode 100644 index 0000000000000000000000000000000000000000..80c1af8c50d47c9631578f82fc1c5f31bf2ecf99 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/TimberLogger.kt @@ -0,0 +1,121 @@ +package com.fsck.k9 + +import android.os.Build +import com.fsck.k9.logging.Logger +import java.util.regex.Pattern +import timber.log.Timber + +class TimberLogger : Logger { + override fun v(message: String?, vararg args: Any?) { + setTimberTag() + Timber.v(message, *args) + } + + override fun v(t: Throwable?, message: String?, vararg args: Any?) { + setTimberTag() + Timber.v(t, message, *args) + } + + override fun v(t: Throwable?) { + setTimberTag() + Timber.v(t) + } + + override fun d(message: String?, vararg args: Any?) { + setTimberTag() + Timber.d(message, *args) + } + + override fun d(t: Throwable?, message: String?, vararg args: Any?) { + setTimberTag() + Timber.d(t, message, *args) + } + + override fun d(t: Throwable?) { + setTimberTag() + Timber.d(t) + } + + override fun i(message: String?, vararg args: Any?) { + setTimberTag() + Timber.i(message, *args) + } + + override fun i(t: Throwable?, message: String?, vararg args: Any?) { + setTimberTag() + Timber.i(t, message, *args) + } + + override fun i(t: Throwable?) { + setTimberTag() + Timber.i(t) + } + + override fun w(message: String?, vararg args: Any?) { + setTimberTag() + Timber.w(message, *args) + } + + override fun w(t: Throwable?, message: String?, vararg args: Any?) { + setTimberTag() + Timber.w(t, message, *args) + } + + override fun w(t: Throwable?) { + setTimberTag() + Timber.w(t) + } + + override fun e(message: String?, vararg args: Any?) { + setTimberTag() + Timber.e(message, *args) + } + + override fun e(t: Throwable?, message: String?, vararg args: Any?) { + setTimberTag() + Timber.e(t, message, *args) + } + + override fun e(t: Throwable?) { + setTimberTag() + Timber.e(t) + } + + private fun setTimberTag() { + val tag = Throwable().stackTrace + .first { it.className !in IGNORE_CLASSES } + .let(::createStackElementTag) + + // We explicitly set a tag, otherwise Timber will always derive the tag "TimberLogger". + Timber.tag(tag) + } + + private fun createStackElementTag(element: StackTraceElement): String { + var tag = element.className.substringAfterLast('.') + val matcher = ANONYMOUS_CLASS.matcher(tag) + if (matcher.find()) { + tag = matcher.replaceAll("") + } + + // Tag length limit was removed in API 26. + return if (tag.length <= MAX_TAG_LENGTH || Build.VERSION.SDK_INT >= 26) { + tag + } else { + tag.substring(0, MAX_TAG_LENGTH) + } + } + + companion object { + private const val MAX_TAG_LENGTH = 23 + private val ANONYMOUS_CLASS = Pattern.compile("(\\$\\d+)+$") + + private val IGNORE_CLASSES = setOf( + Timber::class.java.name, + Timber.Forest::class.java.name, + Timber.Tree::class.java.name, + Timber.DebugTree::class.java.name, + TimberLogger::class.java.name, + com.fsck.k9.logging.Timber::class.java.name + ) + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/mail/ssl/DefaultTrustedSocketFactory.java b/app/core/src/main/java/com/fsck/k9/helper/DefaultTrustedSocketFactory.java similarity index 97% rename from mail/common/src/main/java/com/fsck/k9/mail/ssl/DefaultTrustedSocketFactory.java rename to app/core/src/main/java/com/fsck/k9/helper/DefaultTrustedSocketFactory.java index 4c9857e024a4f5eebae5be2edae1dd62137700f5..ce046daa9e1e5a93f251f341b6ae99b192f9c93b 100644 --- a/mail/common/src/main/java/com/fsck/k9/mail/ssl/DefaultTrustedSocketFactory.java +++ b/app/core/src/main/java/com/fsck/k9/helper/DefaultTrustedSocketFactory.java @@ -1,4 +1,4 @@ -package com.fsck.k9.mail.ssl; +package com.fsck.k9.helper; import java.io.IOException; @@ -14,6 +14,8 @@ import android.net.SSLCertificateSocketFactory; import android.text.TextUtils; import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.ssl.TrustManagerFactory; +import com.fsck.k9.mail.ssl.TrustedSocketFactory; import javax.net.ssl.KeyManager; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSocket; diff --git a/mail/common/src/main/java/com/fsck/k9/mail/ssl/KeyChainKeyManager.java b/app/core/src/main/java/com/fsck/k9/helper/KeyChainKeyManager.java similarity index 99% rename from mail/common/src/main/java/com/fsck/k9/mail/ssl/KeyChainKeyManager.java rename to app/core/src/main/java/com/fsck/k9/helper/KeyChainKeyManager.java index bfd90c196c9960b58861787dcd3f160d26b4756b..77e9fc4f834ac2460a97d142920237b2b68f636b 100644 --- a/mail/common/src/main/java/com/fsck/k9/mail/ssl/KeyChainKeyManager.java +++ b/app/core/src/main/java/com/fsck/k9/helper/KeyChainKeyManager.java @@ -1,5 +1,5 @@ -package com.fsck.k9.mail.ssl; +package com.fsck.k9.helper; import java.net.Socket; import java.security.Principal; diff --git a/mail/common/src/main/java/com/fsck/k9/mail/internet/ListHeaders.java b/app/core/src/main/java/com/fsck/k9/helper/ListHeaders.java similarity index 96% rename from mail/common/src/main/java/com/fsck/k9/mail/internet/ListHeaders.java rename to app/core/src/main/java/com/fsck/k9/helper/ListHeaders.java index 2bed0bafcc4885c6ef3b5963c6f398119c1dc381..52f16846078c0a4c4abc0c7eae3c2bd393d00af1 100644 --- a/mail/common/src/main/java/com/fsck/k9/mail/internet/ListHeaders.java +++ b/app/core/src/main/java/com/fsck/k9/helper/ListHeaders.java @@ -1,4 +1,4 @@ -package com.fsck.k9.mail.internet; +package com.fsck.k9.helper; import android.net.Uri; @@ -8,7 +8,6 @@ import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; -import com.fsck.k9.helper.MailTo; import com.fsck.k9.mail.Address; import com.fsck.k9.mail.Message; diff --git a/app/core/src/main/java/com/fsck/k9/helper/ListUnsubscribeHelper.kt b/app/core/src/main/java/com/fsck/k9/helper/ListUnsubscribeHelper.kt new file mode 100644 index 0000000000000000000000000000000000000000..08090265372531485ba941b1414b8a84986acc3e --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/helper/ListUnsubscribeHelper.kt @@ -0,0 +1,55 @@ +package com.fsck.k9.helper + +import android.net.Uri +import com.fsck.k9.mail.Message +import java.util.regex.Pattern + +object ListUnsubscribeHelper { + private const val LIST_UNSUBSCRIBE_HEADER = "List-Unsubscribe" + private val MAILTO_CONTAINER_PATTERN = Pattern.compile("<(mailto:.+?)>") + private val HTTPS_CONTAINER_PATTERN = Pattern.compile("<(https:.+?)>") + + // As K-9 Mail is an email client, we prefer a mailto: unsubscribe method + // but if none is found, a https URL is acceptable too + fun getPreferredListUnsubscribeUri(message: Message): UnsubscribeUri? { + val headerValues = message.getHeader(LIST_UNSUBSCRIBE_HEADER) + if (headerValues.isEmpty()) { + return null + } + val listUnsubscribeUris = mutableListOf() + for (headerValue in headerValues) { + val uri = extractUri(headerValue) ?: continue + + if (uri.scheme == "mailto") { + return MailtoUnsubscribeUri(uri) + } + + // If we got here it must be HTTPS + listUnsubscribeUris.add(uri) + } + + if (listUnsubscribeUris.isNotEmpty()) { + return HttpsUnsubscribeUri(listUnsubscribeUris[0]) + } + + return null + } + + private fun extractUri(headerValue: String?): Uri? { + if (headerValue == null || headerValue.isEmpty()) { + return null + } + + var matcher = MAILTO_CONTAINER_PATTERN.matcher(headerValue) + if (matcher.find()) { + return Uri.parse(matcher.group(1)) + } + + matcher = HTTPS_CONTAINER_PATTERN.matcher(headerValue) + if (matcher.find()) { + return Uri.parse(matcher.group(1)) + } + + return null + } +} diff --git a/mail/common/src/main/java/com/fsck/k9/helper/MailTo.java b/app/core/src/main/java/com/fsck/k9/helper/MailTo.java similarity index 100% rename from mail/common/src/main/java/com/fsck/k9/helper/MailTo.java rename to app/core/src/main/java/com/fsck/k9/helper/MailTo.java diff --git a/app/core/src/main/java/com/fsck/k9/helper/MimeTypeUtil.java b/app/core/src/main/java/com/fsck/k9/helper/MimeTypeUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..57266fa000fa4b750a789e9e38f06b31f8aacced --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/helper/MimeTypeUtil.java @@ -0,0 +1,925 @@ +package com.fsck.k9.helper; + + +import java.util.Locale; + +import org.jetbrains.annotations.NotNull; + + +public class MimeTypeUtil { + public static final String DEFAULT_ATTACHMENT_MIME_TYPE = "application/octet-stream"; + public static final String K9_SETTINGS_MIME_TYPE = "application/x-k9settings"; + + /* + * http://www.w3schools.com/media/media_mimeref.asp + * + + * http://www.stdicon.com/mimetypes + */ + static final String[][] MIME_TYPE_BY_EXTENSION_MAP = new String[][] { + //* Do not delete the next three lines + { "", DEFAULT_ATTACHMENT_MIME_TYPE }, + { "k9s", K9_SETTINGS_MIME_TYPE }, + { "txt", "text/plain" }, + //* Do not delete the previous three lines + { "123", "application/vnd.lotus-1-2-3" }, + { "323", "text/h323" }, + { "3dml", "text/vnd.in3d.3dml" }, + { "3g2", "video/3gpp2" }, + { "3gp", "video/3gpp" }, + { "aab", "application/x-authorware-bin" }, + { "aac", "audio/x-aac" }, + { "aam", "application/x-authorware-map" }, + { "a", "application/octet-stream" }, + { "aas", "application/x-authorware-seg" }, + { "abw", "application/x-abiword" }, + { "acc", "application/vnd.americandynamics.acc" }, + { "ace", "application/x-ace-compressed" }, + { "acu", "application/vnd.acucobol" }, + { "acutc", "application/vnd.acucorp" }, + { "acx", "application/internet-property-stream" }, + { "adp", "audio/adpcm" }, + { "aep", "application/vnd.audiograph" }, + { "afm", "application/x-font-type1" }, + { "afp", "application/vnd.ibm.modcap" }, + { "ai", "application/postscript" }, + { "aif", "audio/x-aiff" }, + { "aifc", "audio/x-aiff" }, + { "aiff", "audio/x-aiff" }, + { "air", "application/vnd.adobe.air-application-installer-package+zip" }, + { "ami", "application/vnd.amiga.ami" }, + { "apk", "application/vnd.android.package-archive" }, + { "application", "application/x-ms-application" }, + { "apr", "application/vnd.lotus-approach" }, + { "asc", "application/pgp-signature" }, + { "asf", "video/x-ms-asf" }, + { "asm", "text/x-asm" }, + { "aso", "application/vnd.accpac.simply.aso" }, + { "asr", "video/x-ms-asf" }, + { "asx", "video/x-ms-asf" }, + { "atc", "application/vnd.acucorp" }, + { "atom", "application/atom+xml" }, + { "atomcat", "application/atomcat+xml" }, + { "atomsvc", "application/atomsvc+xml" }, + { "atx", "application/vnd.antix.game-component" }, + { "au", "audio/basic" }, + { "avi", "video/x-msvideo" }, + { "aw", "application/applixware" }, + { "axs", "application/olescript" }, + { "azf", "application/vnd.airzip.filesecure.azf" }, + { "azs", "application/vnd.airzip.filesecure.azs" }, + { "azw", "application/vnd.amazon.ebook" }, + { "bas", "text/plain" }, + { "bat", "application/x-msdownload" }, + { "bcpio", "application/x-bcpio" }, + { "bdf", "application/x-font-bdf" }, + { "bdm", "application/vnd.syncml.dm+wbxml" }, + { "bh2", "application/vnd.fujitsu.oasysprs" }, + { "bin", "application/octet-stream" }, + { "bmi", "application/vnd.bmi" }, + { "bmp", "image/bmp" }, + { "book", "application/vnd.framemaker" }, + { "box", "application/vnd.previewsystems.box" }, + { "boz", "application/x-bzip2" }, + { "bpk", "application/octet-stream" }, + { "btif", "image/prs.btif" }, + { "bz2", "application/x-bzip2" }, + { "bz", "application/x-bzip" }, + { "c4d", "application/vnd.clonk.c4group" }, + { "c4f", "application/vnd.clonk.c4group" }, + { "c4g", "application/vnd.clonk.c4group" }, + { "c4p", "application/vnd.clonk.c4group" }, + { "c4u", "application/vnd.clonk.c4group" }, + { "cab", "application/vnd.ms-cab-compressed" }, + { "car", "application/vnd.curl.car" }, + { "cat", "application/vnd.ms-pki.seccat" }, + { "cct", "application/x-director" }, + { "cc", "text/x-c" }, + { "ccxml", "application/ccxml+xml" }, + { "cdbcmsg", "application/vnd.contact.cmsg" }, + { "cdf", "application/x-cdf" }, + { "cdkey", "application/vnd.mediastation.cdkey" }, + { "cdx", "chemical/x-cdx" }, + { "cdxml", "application/vnd.chemdraw+xml" }, + { "cdy", "application/vnd.cinderella" }, + { "cer", "application/x-x509-ca-cert" }, + { "cgm", "image/cgm" }, + { "chat", "application/x-chat" }, + { "chm", "application/vnd.ms-htmlhelp" }, + { "chrt", "application/vnd.kde.kchart" }, + { "cif", "chemical/x-cif" }, + { "cii", "application/vnd.anser-web-certificate-issue-initiation" }, + { "cla", "application/vnd.claymore" }, + { "class", "application/java-vm" }, + { "clkk", "application/vnd.crick.clicker.keyboard" }, + { "clkp", "application/vnd.crick.clicker.palette" }, + { "clkt", "application/vnd.crick.clicker.template" }, + { "clkw", "application/vnd.crick.clicker.wordbank" }, + { "clkx", "application/vnd.crick.clicker" }, + { "clp", "application/x-msclip" }, + { "cmc", "application/vnd.cosmocaller" }, + { "cmdf", "chemical/x-cmdf" }, + { "cml", "chemical/x-cml" }, + { "cmp", "application/vnd.yellowriver-custom-menu" }, + { "cmx", "image/x-cmx" }, + { "cod", "application/vnd.rim.cod" }, + { "com", "application/x-msdownload" }, + { "conf", "text/plain" }, + { "cpio", "application/x-cpio" }, + { "cpp", "text/x-c" }, + { "cpt", "application/mac-compactpro" }, + { "crd", "application/x-mscardfile" }, + { "crl", "application/pkix-crl" }, + { "crt", "application/x-x509-ca-cert" }, + { "csh", "application/x-csh" }, + { "csml", "chemical/x-csml" }, + { "csp", "application/vnd.commonspace" }, + { "css", "text/css" }, + { "cst", "application/x-director" }, + { "csv", "text/csv" }, + { "c", "text/plain" }, + { "cu", "application/cu-seeme" }, + { "curl", "text/vnd.curl" }, + { "cww", "application/prs.cww" }, + { "cxt", "application/x-director" }, + { "cxx", "text/x-c" }, + { "daf", "application/vnd.mobius.daf" }, + { "dataless", "application/vnd.fdsn.seed" }, + { "davmount", "application/davmount+xml" }, + { "dcr", "application/x-director" }, + { "dcurl", "text/vnd.curl.dcurl" }, + { "dd2", "application/vnd.oma.dd2+xml" }, + { "ddd", "application/vnd.fujixerox.ddd" }, + { "deb", "application/x-debian-package" }, + { "def", "text/plain" }, + { "deploy", "application/octet-stream" }, + { "der", "application/x-x509-ca-cert" }, + { "dfac", "application/vnd.dreamfactory" }, + { "dic", "text/x-c" }, + { "diff", "text/plain" }, + { "dir", "application/x-director" }, + { "dis", "application/vnd.mobius.dis" }, + { "dist", "application/octet-stream" }, + { "distz", "application/octet-stream" }, + { "djv", "image/vnd.djvu" }, + { "djvu", "image/vnd.djvu" }, + { "dll", "application/x-msdownload" }, + { "dmg", "application/octet-stream" }, + { "dms", "application/octet-stream" }, + { "dna", "application/vnd.dna" }, + { "doc", "application/msword" }, + { "docm", "application/vnd.ms-word.document.macroenabled.12" }, + { "docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }, + { "dot", "application/msword" }, + { "dotm", "application/vnd.ms-word.template.macroenabled.12" }, + { "dotx", "application/vnd.openxmlformats-officedocument.wordprocessingml.template" }, + { "dp", "application/vnd.osgi.dp" }, + { "dpg", "application/vnd.dpgraph" }, + { "dsc", "text/prs.lines.tag" }, + { "dtb", "application/x-dtbook+xml" }, + { "dtd", "application/xml-dtd" }, + { "dts", "audio/vnd.dts" }, + { "dtshd", "audio/vnd.dts.hd" }, + { "dump", "application/octet-stream" }, + { "dvi", "application/x-dvi" }, + { "dwf", "model/vnd.dwf" }, + { "dwg", "image/vnd.dwg" }, + { "dxf", "image/vnd.dxf" }, + { "dxp", "application/vnd.spotfire.dxp" }, + { "dxr", "application/x-director" }, + { "ecelp4800", "audio/vnd.nuera.ecelp4800" }, + { "ecelp7470", "audio/vnd.nuera.ecelp7470" }, + { "ecelp9600", "audio/vnd.nuera.ecelp9600" }, + { "ecma", "application/ecmascript" }, + { "edm", "application/vnd.novadigm.edm" }, + { "edx", "application/vnd.novadigm.edx" }, + { "efif", "application/vnd.picsel" }, + { "ei6", "application/vnd.pg.osasli" }, + { "elc", "application/octet-stream" }, + { "eml", "message/rfc822" }, + { "emma", "application/emma+xml" }, + { "eol", "audio/vnd.digital-winds" }, + { "eot", "application/vnd.ms-fontobject" }, + { "eps", "application/postscript" }, + { "epub", "application/epub+zip" }, + { "es3", "application/vnd.eszigno3+xml" }, + { "esf", "application/vnd.epson.esf" }, + { "espass", "application/vnd.espass-espass+zip" }, + { "et3", "application/vnd.eszigno3+xml" }, + { "etx", "text/x-setext" }, + { "evy", "application/envoy" }, + { "exe", "application/octet-stream" }, + { "ext", "application/vnd.novadigm.ext" }, + { "ez2", "application/vnd.ezpix-album" }, + { "ez3", "application/vnd.ezpix-package" }, + { "ez", "application/andrew-inset" }, + { "f4v", "video/x-f4v" }, + { "f77", "text/x-fortran" }, + { "f90", "text/x-fortran" }, + { "fbs", "image/vnd.fastbidsheet" }, + { "fdf", "application/vnd.fdf" }, + { "fe_launch", "application/vnd.denovo.fcselayout-link" }, + { "fg5", "application/vnd.fujitsu.oasysgp" }, + { "fgd", "application/x-director" }, + { "fh4", "image/x-freehand" }, + { "fh5", "image/x-freehand" }, + { "fh7", "image/x-freehand" }, + { "fhc", "image/x-freehand" }, + { "fh", "image/x-freehand" }, + { "fif", "application/fractals" }, + { "fig", "application/x-xfig" }, + { "fli", "video/x-fli" }, + { "flo", "application/vnd.micrografx.flo" }, + { "flr", "x-world/x-vrml" }, + { "flv", "video/x-flv" }, + { "flw", "application/vnd.kde.kivio" }, + { "flx", "text/vnd.fmi.flexstor" }, + { "fly", "text/vnd.fly" }, + { "fm", "application/vnd.framemaker" }, + { "fnc", "application/vnd.frogans.fnc" }, + { "for", "text/x-fortran" }, + { "fpx", "image/vnd.fpx" }, + { "frame", "application/vnd.framemaker" }, + { "fsc", "application/vnd.fsc.weblaunch" }, + { "fst", "image/vnd.fst" }, + { "ftc", "application/vnd.fluxtime.clip" }, + { "f", "text/x-fortran" }, + { "fti", "application/vnd.anser-web-funds-transfer-initiation" }, + { "fvt", "video/vnd.fvt" }, + { "fzs", "application/vnd.fuzzysheet" }, + { "g3", "image/g3fax" }, + { "gac", "application/vnd.groove-account" }, + { "gdl", "model/vnd.gdl" }, + { "geo", "application/vnd.dynageo" }, + { "gex", "application/vnd.geometry-explorer" }, + { "ggb", "application/vnd.geogebra.file" }, + { "ggt", "application/vnd.geogebra.tool" }, + { "ghf", "application/vnd.groove-help" }, + { "gif", "image/gif" }, + { "gim", "application/vnd.groove-identity-message" }, + { "gmx", "application/vnd.gmx" }, + { "gnumeric", "application/x-gnumeric" }, + { "gph", "application/vnd.flographit" }, + { "gqf", "application/vnd.grafeq" }, + { "gqs", "application/vnd.grafeq" }, + { "gram", "application/srgs" }, + { "gre", "application/vnd.geometry-explorer" }, + { "grv", "application/vnd.groove-injector" }, + { "grxml", "application/srgs+xml" }, + { "gsf", "application/x-font-ghostscript" }, + { "gtar", "application/x-gtar" }, + { "gtm", "application/vnd.groove-tool-message" }, + { "gtw", "model/vnd.gtw" }, + { "gv", "text/vnd.graphviz" }, + { "gz", "application/x-gzip" }, + { "h261", "video/h261" }, + { "h263", "video/h263" }, + { "h264", "video/h264" }, + { "hbci", "application/vnd.hbci" }, + { "hdf", "application/x-hdf" }, + { "hh", "text/x-c" }, + { "hlp", "application/winhlp" }, + { "hpgl", "application/vnd.hp-hpgl" }, + { "hpid", "application/vnd.hp-hpid" }, + { "hps", "application/vnd.hp-hps" }, + { "hqx", "application/mac-binhex40" }, + { "hta", "application/hta" }, + { "htc", "text/x-component" }, + { "h", "text/plain" }, + { "htke", "application/vnd.kenameaapp" }, + { "html", "text/html" }, + { "htm", "text/html" }, + { "htt", "text/webviewhtml" }, + { "hvd", "application/vnd.yamaha.hv-dic" }, + { "hvp", "application/vnd.yamaha.hv-voice" }, + { "hvs", "application/vnd.yamaha.hv-script" }, + { "icc", "application/vnd.iccprofile" }, + { "ice", "x-conference/x-cooltalk" }, + { "icm", "application/vnd.iccprofile" }, + { "ico", "image/x-icon" }, + { "ics", "text/calendar" }, + { "ief", "image/ief" }, + { "ifb", "text/calendar" }, + { "ifm", "application/vnd.shana.informed.formdata" }, + { "iges", "model/iges" }, + { "igl", "application/vnd.igloader" }, + { "igs", "model/iges" }, + { "igx", "application/vnd.micrografx.igx" }, + { "iif", "application/vnd.shana.informed.interchange" }, + { "iii", "application/x-iphone" }, + { "imp", "application/vnd.accpac.simply.imp" }, + { "ims", "application/vnd.ms-ims" }, + { "ins", "application/x-internet-signup" }, + { "in", "text/plain" }, + { "ipk", "application/vnd.shana.informed.package" }, + { "irm", "application/vnd.ibm.rights-management" }, + { "irp", "application/vnd.irepository.package+xml" }, + { "iso", "application/octet-stream" }, + { "isp", "application/x-internet-signup" }, + { "itp", "application/vnd.shana.informed.formtemplate" }, + { "ivp", "application/vnd.immervision-ivp" }, + { "ivu", "application/vnd.immervision-ivu" }, + { "jad", "text/vnd.sun.j2me.app-descriptor" }, + { "jam", "application/vnd.jam" }, + { "jar", "application/java-archive" }, + { "java", "text/x-java-source" }, + { "jfif", "image/pipeg" }, + { "jisp", "application/vnd.jisp" }, + { "jlt", "application/vnd.hp-jlyt" }, + { "jnlp", "application/x-java-jnlp-file" }, + { "joda", "application/vnd.joost.joda-archive" }, + { "jpeg", "image/jpeg" }, + { "jpe", "image/jpeg" }, + { "jpg", "image/jpeg" }, + { "jpgm", "video/jpm" }, + { "jpgv", "video/jpeg" }, + { "jpm", "video/jpm" }, + { "js", "application/x-javascript" }, + { "json", "application/json" }, + { "kar", "audio/midi" }, + { "karbon", "application/vnd.kde.karbon" }, + { "kfo", "application/vnd.kde.kformula" }, + { "kia", "application/vnd.kidspiration" }, + { "kil", "application/x-killustrator" }, + { "kml", "application/vnd.google-earth.kml+xml" }, + { "kmz", "application/vnd.google-earth.kmz" }, + { "kne", "application/vnd.kinar" }, + { "knp", "application/vnd.kinar" }, + { "kon", "application/vnd.kde.kontour" }, + { "kpr", "application/vnd.kde.kpresenter" }, + { "kpt", "application/vnd.kde.kpresenter" }, + { "ksh", "text/plain" }, + { "ksp", "application/vnd.kde.kspread" }, + { "ktr", "application/vnd.kahootz" }, + { "ktz", "application/vnd.kahootz" }, + { "kwd", "application/vnd.kde.kword" }, + { "kwt", "application/vnd.kde.kword" }, + { "latex", "application/x-latex" }, + { "lbd", "application/vnd.llamagraphics.life-balance.desktop" }, + { "lbe", "application/vnd.llamagraphics.life-balance.exchange+xml" }, + { "les", "application/vnd.hhe.lesson-player" }, + { "lha", "application/octet-stream" }, + { "link66", "application/vnd.route66.link66+xml" }, + { "list3820", "application/vnd.ibm.modcap" }, + { "listafp", "application/vnd.ibm.modcap" }, + { "list", "text/plain" }, + { "log", "text/plain" }, + { "lostxml", "application/lost+xml" }, + { "lrf", "application/octet-stream" }, + { "lrm", "application/vnd.ms-lrm" }, + { "lsf", "video/x-la-asf" }, + { "lsx", "video/x-la-asf" }, + { "ltf", "application/vnd.frogans.ltf" }, + { "lvp", "audio/vnd.lucent.voice" }, + { "lwp", "application/vnd.lotus-wordpro" }, + { "lzh", "application/octet-stream" }, + { "m13", "application/x-msmediaview" }, + { "m14", "application/x-msmediaview" }, + { "m1v", "video/mpeg" }, + { "m2a", "audio/mpeg" }, + { "m2v", "video/mpeg" }, + { "m3a", "audio/mpeg" }, + { "m3u", "audio/x-mpegurl" }, + { "m4u", "video/vnd.mpegurl" }, + { "m4v", "video/x-m4v" }, + { "ma", "application/mathematica" }, + { "mag", "application/vnd.ecowin.chart" }, + { "maker", "application/vnd.framemaker" }, + { "man", "text/troff" }, + { "mathml", "application/mathml+xml" }, + { "mb", "application/mathematica" }, + { "mbk", "application/vnd.mobius.mbk" }, + { "mbox", "application/mbox" }, + { "mc1", "application/vnd.medcalcdata" }, + { "mcd", "application/vnd.mcd" }, + { "mcurl", "text/vnd.curl.mcurl" }, + { "mdb", "application/x-msaccess" }, + { "mdi", "image/vnd.ms-modi" }, + { "mesh", "model/mesh" }, + { "me", "text/troff" }, + { "mfm", "application/vnd.mfmp" }, + { "mgz", "application/vnd.proteus.magazine" }, + { "mht", "message/rfc822" }, + { "mhtml", "message/rfc822" }, + { "mid", "audio/midi" }, + { "midi", "audio/midi" }, + { "mif", "application/vnd.mif" }, + { "mime", "message/rfc822" }, + { "mj2", "video/mj2" }, + { "mjp2", "video/mj2" }, + { "mlp", "application/vnd.dolby.mlp" }, + { "mmd", "application/vnd.chipnuts.karaoke-mmd" }, + { "mmf", "application/vnd.smaf" }, + { "mmr", "image/vnd.fujixerox.edmics-mmr" }, + { "mny", "application/x-msmoney" }, + { "mobi", "application/x-mobipocket-ebook" }, + { "movie", "video/x-sgi-movie" }, + { "mov", "video/quicktime" }, + { "mp2a", "audio/mpeg" }, + { "mp2", "video/mpeg" }, + { "mp3", "audio/mpeg" }, + { "mp4a", "audio/mp4" }, + { "mp4s", "application/mp4" }, + { "mp4", "video/mp4" }, + { "mp4v", "video/mp4" }, + { "mpa", "video/mpeg" }, + { "mpc", "application/vnd.mophun.certificate" }, + { "mpeg", "video/mpeg" }, + { "mpe", "video/mpeg" }, + { "mpg4", "video/mp4" }, + { "mpga", "audio/mpeg" }, + { "mpg", "video/mpeg" }, + { "mpkg", "application/vnd.apple.installer+xml" }, + { "mpm", "application/vnd.blueice.multipass" }, + { "mpn", "application/vnd.mophun.application" }, + { "mpp", "application/vnd.ms-project" }, + { "mpt", "application/vnd.ms-project" }, + { "mpv2", "video/mpeg" }, + { "mpy", "application/vnd.ibm.minipay" }, + { "mqy", "application/vnd.mobius.mqy" }, + { "mrc", "application/marc" }, + { "mscml", "application/mediaservercontrol+xml" }, + { "mseed", "application/vnd.fdsn.mseed" }, + { "mseq", "application/vnd.mseq" }, + { "msf", "application/vnd.epson.msf" }, + { "msh", "model/mesh" }, + { "msi", "application/x-msdownload" }, + { "ms", "text/troff" }, + { "msty", "application/vnd.muvee.style" }, + { "mts", "model/vnd.mts" }, + { "mus", "application/vnd.musician" }, + { "musicxml", "application/vnd.recordare.musicxml+xml" }, + { "mvb", "application/x-msmediaview" }, + { "mxf", "application/mxf" }, + { "mxl", "application/vnd.recordare.musicxml" }, + { "mxml", "application/xv+xml" }, + { "mxs", "application/vnd.triscape.mxs" }, + { "mxu", "video/vnd.mpegurl" }, + { "nb", "application/mathematica" }, + { "nc", "application/x-netcdf" }, + { "ncx", "application/x-dtbncx+xml" }, + { "n-gage", "application/vnd.nokia.n-gage.symbian.install" }, + { "ngdat", "application/vnd.nokia.n-gage.data" }, + { "nlu", "application/vnd.neurolanguage.nlu" }, + { "nml", "application/vnd.enliven" }, + { "nnd", "application/vnd.noblenet-directory" }, + { "nns", "application/vnd.noblenet-sealer" }, + { "nnw", "application/vnd.noblenet-web" }, + { "npx", "image/vnd.net-fpx" }, + { "nsf", "application/vnd.lotus-notes" }, + { "nws", "message/rfc822" }, + { "oa2", "application/vnd.fujitsu.oasys2" }, + { "oa3", "application/vnd.fujitsu.oasys3" }, + { "o", "application/octet-stream" }, + { "oas", "application/vnd.fujitsu.oasys" }, + { "obd", "application/x-msbinder" }, + { "obj", "application/octet-stream" }, + { "oda", "application/oda" }, + { "odb", "application/vnd.oasis.opendocument.database" }, + { "odc", "application/vnd.oasis.opendocument.chart" }, + { "odf", "application/vnd.oasis.opendocument.formula" }, + { "odft", "application/vnd.oasis.opendocument.formula-template" }, + { "odg", "application/vnd.oasis.opendocument.graphics" }, + { "odi", "application/vnd.oasis.opendocument.image" }, + { "odp", "application/vnd.oasis.opendocument.presentation" }, + { "ods", "application/vnd.oasis.opendocument.spreadsheet" }, + { "odt", "application/vnd.oasis.opendocument.text" }, + { "oga", "audio/ogg" }, + { "ogg", "audio/ogg" }, + { "ogv", "video/ogg" }, + { "ogx", "application/ogg" }, + { "onepkg", "application/onenote" }, + { "onetmp", "application/onenote" }, + { "onetoc2", "application/onenote" }, + { "onetoc", "application/onenote" }, + { "opf", "application/oebps-package+xml" }, + { "oprc", "application/vnd.palm" }, + { "org", "application/vnd.lotus-organizer" }, + { "osf", "application/vnd.yamaha.openscoreformat" }, + { "osfpvg", "application/vnd.yamaha.openscoreformat.osfpvg+xml" }, + { "otc", "application/vnd.oasis.opendocument.chart-template" }, + { "otf", "application/x-font-otf" }, + { "otg", "application/vnd.oasis.opendocument.graphics-template" }, + { "oth", "application/vnd.oasis.opendocument.text-web" }, + { "oti", "application/vnd.oasis.opendocument.image-template" }, + { "otm", "application/vnd.oasis.opendocument.text-master" }, + { "otp", "application/vnd.oasis.opendocument.presentation-template" }, + { "ots", "application/vnd.oasis.opendocument.spreadsheet-template" }, + { "ott", "application/vnd.oasis.opendocument.text-template" }, + { "oxt", "application/vnd.openofficeorg.extension" }, + { "p10", "application/pkcs10" }, + { "p12", "application/x-pkcs12" }, + { "p7b", "application/x-pkcs7-certificates" }, + { "p7c", "application/x-pkcs7-mime" }, + { "p7m", "application/x-pkcs7-mime" }, + { "p7r", "application/x-pkcs7-certreqresp" }, + { "p7s", "application/x-pkcs7-signature" }, + { "pas", "text/x-pascal" }, + { "pbd", "application/vnd.powerbuilder6" }, + { "pbm", "image/x-portable-bitmap" }, + { "pcf", "application/x-font-pcf" }, + { "pcl", "application/vnd.hp-pcl" }, + { "pclxl", "application/vnd.hp-pclxl" }, + { "pct", "image/x-pict" }, + { "pcurl", "application/vnd.curl.pcurl" }, + { "pcx", "image/x-pcx" }, + { "pdb", "application/vnd.palm" }, + { "pdf", "application/pdf" }, + { "pfa", "application/x-font-type1" }, + { "pfb", "application/x-font-type1" }, + { "pfm", "application/x-font-type1" }, + { "pfr", "application/font-tdpfr" }, + { "pfx", "application/x-pkcs12" }, + { "pgm", "image/x-portable-graymap" }, + { "pgn", "application/x-chess-pgn" }, + { "pgp", "application/pgp-encrypted" }, + { "pic", "image/x-pict" }, + { "pkg", "application/octet-stream" }, + { "pki", "application/pkixcmp" }, + { "pkipath", "application/pkix-pkipath" }, + { "pkpass", "application/vnd-com.apple.pkpass" }, + { "pko", "application/ynd.ms-pkipko" }, + { "plb", "application/vnd.3gpp.pic-bw-large" }, + { "plc", "application/vnd.mobius.plc" }, + { "plf", "application/vnd.pocketlearn" }, + { "pls", "application/pls+xml" }, + { "pl", "text/plain" }, + { "pma", "application/x-perfmon" }, + { "pmc", "application/x-perfmon" }, + { "pml", "application/x-perfmon" }, + { "pmr", "application/x-perfmon" }, + { "pmw", "application/x-perfmon" }, + { "png", "image/png" }, + { "pnm", "image/x-portable-anymap" }, + { "portpkg", "application/vnd.macports.portpkg" }, + { "pot,", "application/vnd.ms-powerpoint" }, + { "pot", "application/vnd.ms-powerpoint" }, + { "potm", "application/vnd.ms-powerpoint.template.macroenabled.12" }, + { "potx", "application/vnd.openxmlformats-officedocument.presentationml.template" }, + { "ppa", "application/vnd.ms-powerpoint" }, + { "ppam", "application/vnd.ms-powerpoint.addin.macroenabled.12" }, + { "ppd", "application/vnd.cups-ppd" }, + { "ppm", "image/x-portable-pixmap" }, + { "pps", "application/vnd.ms-powerpoint" }, + { "ppsm", "application/vnd.ms-powerpoint.slideshow.macroenabled.12" }, + { "ppsx", "application/vnd.openxmlformats-officedocument.presentationml.slideshow" }, + { "ppt", "application/vnd.ms-powerpoint" }, + { "pptm", "application/vnd.ms-powerpoint.presentation.macroenabled.12" }, + { "pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation" }, + { "pqa", "application/vnd.palm" }, + { "prc", "application/x-mobipocket-ebook" }, + { "pre", "application/vnd.lotus-freelance" }, + { "prf", "application/pics-rules" }, + { "ps", "application/postscript" }, + { "psb", "application/vnd.3gpp.pic-bw-small" }, + { "psd", "image/vnd.adobe.photoshop" }, + { "psf", "application/x-font-linux-psf" }, + { "p", "text/x-pascal" }, + { "ptid", "application/vnd.pvi.ptid1" }, + { "pub", "application/x-mspublisher" }, + { "pvb", "application/vnd.3gpp.pic-bw-var" }, + { "pwn", "application/vnd.3m.post-it-notes" }, + { "pwz", "application/vnd.ms-powerpoint" }, + { "pya", "audio/vnd.ms-playready.media.pya" }, + { "pyc", "application/x-python-code" }, + { "pyo", "application/x-python-code" }, + { "py", "text/x-python" }, + { "pyv", "video/vnd.ms-playready.media.pyv" }, + { "qam", "application/vnd.epson.quickanime" }, + { "qbo", "application/vnd.intu.qbo" }, + { "qfx", "application/vnd.intu.qfx" }, + { "qps", "application/vnd.publishare-delta-tree" }, + { "qt", "video/quicktime" }, + { "qwd", "application/vnd.quark.quarkxpress" }, + { "qwt", "application/vnd.quark.quarkxpress" }, + { "qxb", "application/vnd.quark.quarkxpress" }, + { "qxd", "application/vnd.quark.quarkxpress" }, + { "qxl", "application/vnd.quark.quarkxpress" }, + { "qxt", "application/vnd.quark.quarkxpress" }, + { "ra", "audio/x-pn-realaudio" }, + { "ram", "audio/x-pn-realaudio" }, + { "rar", "application/x-rar-compressed" }, + { "ras", "image/x-cmu-raster" }, + { "rcprofile", "application/vnd.ipunplugged.rcprofile" }, + { "rdf", "application/rdf+xml" }, + { "rdz", "application/vnd.data-vision.rdz" }, + { "rep", "application/vnd.businessobjects" }, + { "res", "application/x-dtbresource+xml" }, + { "rgb", "image/x-rgb" }, + { "rif", "application/reginfo+xml" }, + { "rl", "application/resource-lists+xml" }, + { "rlc", "image/vnd.fujixerox.edmics-rlc" }, + { "rld", "application/resource-lists-diff+xml" }, + { "rm", "application/vnd.rn-realmedia" }, + { "rmi", "audio/midi" }, + { "rmp", "audio/x-pn-realaudio-plugin" }, + { "rms", "application/vnd.jcp.javame.midlet-rms" }, + { "rnc", "application/relax-ng-compact-syntax" }, + { "roff", "text/troff" }, + { "rpm", "application/x-rpm" }, + { "rpss", "application/vnd.nokia.radio-presets" }, + { "rpst", "application/vnd.nokia.radio-preset" }, + { "rq", "application/sparql-query" }, + { "rs", "application/rls-services+xml" }, + { "rsd", "application/rsd+xml" }, + { "rss", "application/rss+xml" }, + { "rtf", "application/rtf" }, + { "rtx", "text/richtext" }, + { "saf", "application/vnd.yamaha.smaf-audio" }, + { "sbml", "application/sbml+xml" }, + { "sc", "application/vnd.ibm.secure-container" }, + { "scd", "application/x-msschedule" }, + { "scm", "application/vnd.lotus-screencam" }, + { "scq", "application/scvp-cv-request" }, + { "scs", "application/scvp-cv-response" }, + { "sct", "text/scriptlet" }, + { "scurl", "text/vnd.curl.scurl" }, + { "sda", "application/vnd.stardivision.draw" }, + { "sdc", "application/vnd.stardivision.calc" }, + { "sdd", "application/vnd.stardivision.impress" }, + { "sdkd", "application/vnd.solent.sdkm+xml" }, + { "sdkm", "application/vnd.solent.sdkm+xml" }, + { "sdp", "application/sdp" }, + { "sdw", "application/vnd.stardivision.writer" }, + { "see", "application/vnd.seemail" }, + { "seed", "application/vnd.fdsn.seed" }, + { "sema", "application/vnd.sema" }, + { "semd", "application/vnd.semd" }, + { "semf", "application/vnd.semf" }, + { "ser", "application/java-serialized-object" }, + { "setpay", "application/set-payment-initiation" }, + { "setreg", "application/set-registration-initiation" }, + { "sfd-hdstx", "application/vnd.hydrostatix.sof-data" }, + { "sfs", "application/vnd.spotfire.sfs" }, + { "sgl", "application/vnd.stardivision.writer-global" }, + { "sgml", "text/sgml" }, + { "sgm", "text/sgml" }, + { "sh", "application/x-sh" }, + { "shar", "application/x-shar" }, + { "shf", "application/shf+xml" }, + { "sic", "application/vnd.wap.sic" }, + { "sig", "application/pgp-signature" }, + { "silo", "model/mesh" }, + { "sis", "application/vnd.symbian.install" }, + { "sisx", "application/vnd.symbian.install" }, + { "sit", "application/x-stuffit" }, + { "si", "text/vnd.wap.si" }, + { "sitx", "application/x-stuffitx" }, + { "skd", "application/vnd.koan" }, + { "skm", "application/vnd.koan" }, + { "skp", "application/vnd.koan" }, + { "skt", "application/vnd.koan" }, + { "slc", "application/vnd.wap.slc" }, + { "sldm", "application/vnd.ms-powerpoint.slide.macroenabled.12" }, + { "sldx", "application/vnd.openxmlformats-officedocument.presentationml.slide" }, + { "slt", "application/vnd.epson.salt" }, + { "sl", "text/vnd.wap.sl" }, + { "smf", "application/vnd.stardivision.math" }, + { "smi", "application/smil+xml" }, + { "smil", "application/smil+xml" }, + { "snd", "audio/basic" }, + { "snf", "application/x-font-snf" }, + { "so", "application/octet-stream" }, + { "spc", "application/x-pkcs7-certificates" }, + { "spf", "application/vnd.yamaha.smaf-phrase" }, + { "spl", "application/x-futuresplash" }, + { "spot", "text/vnd.in3d.spot" }, + { "spp", "application/scvp-vp-response" }, + { "spq", "application/scvp-vp-request" }, + { "spx", "audio/ogg" }, + { "src", "application/x-wais-source" }, + { "srx", "application/sparql-results+xml" }, + { "sse", "application/vnd.kodak-descriptor" }, + { "ssf", "application/vnd.epson.ssf" }, + { "ssml", "application/ssml+xml" }, + { "sst", "application/vnd.ms-pkicertstore" }, + { "stc", "application/vnd.sun.xml.calc.template" }, + { "std", "application/vnd.sun.xml.draw.template" }, + { "s", "text/x-asm" }, + { "stf", "application/vnd.wt.stf" }, + { "sti", "application/vnd.sun.xml.impress.template" }, + { "stk", "application/hyperstudio" }, + { "stl", "application/vnd.ms-pki.stl" }, + { "stm", "text/html" }, + { "str", "application/vnd.pg.format" }, + { "stw", "application/vnd.sun.xml.writer.template" }, + { "sus", "application/vnd.sus-calendar" }, + { "susp", "application/vnd.sus-calendar" }, + { "sv4cpio", "application/x-sv4cpio" }, + { "sv4crc", "application/x-sv4crc" }, + { "svd", "application/vnd.svd" }, + { "svg", "image/svg+xml" }, + { "svgz", "image/svg+xml" }, + { "swa", "application/x-director" }, + { "swf", "application/x-shockwave-flash" }, + { "swi", "application/vnd.arastra.swi" }, + { "sxc", "application/vnd.sun.xml.calc" }, + { "sxd", "application/vnd.sun.xml.draw" }, + { "sxg", "application/vnd.sun.xml.writer.global" }, + { "sxi", "application/vnd.sun.xml.impress" }, + { "sxm", "application/vnd.sun.xml.math" }, + { "sxw", "application/vnd.sun.xml.writer" }, + { "tao", "application/vnd.tao.intent-module-archive" }, + { "t", "application/x-troff" }, + { "tar", "application/x-tar" }, + { "tcap", "application/vnd.3gpp2.tcap" }, + { "tcl", "application/x-tcl" }, + { "teacher", "application/vnd.smart.teacher" }, + { "tex", "application/x-tex" }, + { "texi", "application/x-texinfo" }, + { "texinfo", "application/x-texinfo" }, + { "text", "text/plain" }, + { "tfm", "application/x-tex-tfm" }, + { "tgz", "application/x-gzip" }, + { "tiff", "image/tiff" }, + { "tif", "image/tiff" }, + { "tmo", "application/vnd.tmobile-livetv" }, + { "torrent", "application/x-bittorrent" }, + { "tpl", "application/vnd.groove-tool-template" }, + { "tpt", "application/vnd.trid.tpt" }, + { "tra", "application/vnd.trueapp" }, + { "trm", "application/x-msterminal" }, + { "tr", "text/troff" }, + { "tsv", "text/tab-separated-values" }, + { "ttc", "application/x-font-ttf" }, + { "ttf", "application/x-font-ttf" }, + { "twd", "application/vnd.simtech-mindmapper" }, + { "twds", "application/vnd.simtech-mindmapper" }, + { "txd", "application/vnd.genomatix.tuxedo" }, + { "txf", "application/vnd.mobius.txf" }, + { "txt", "text/plain" }, + { "u32", "application/x-authorware-bin" }, + { "udeb", "application/x-debian-package" }, + { "ufd", "application/vnd.ufdl" }, + { "ufdl", "application/vnd.ufdl" }, + { "uls", "text/iuls" }, + { "umj", "application/vnd.umajin" }, + { "unityweb", "application/vnd.unity" }, + { "uoml", "application/vnd.uoml+xml" }, + { "uris", "text/uri-list" }, + { "uri", "text/uri-list" }, + { "urls", "text/uri-list" }, + { "ustar", "application/x-ustar" }, + { "utz", "application/vnd.uiq.theme" }, + { "uu", "text/x-uuencode" }, + { "vcd", "application/x-cdlink" }, + { "vcf", "text/x-vcard" }, + { "vcg", "application/vnd.groove-vcard" }, + { "vcs", "text/x-vcalendar" }, + { "vcx", "application/vnd.vcx" }, + { "vis", "application/vnd.visionary" }, + { "viv", "video/vnd.vivo" }, + { "vor", "application/vnd.stardivision.writer" }, + { "vox", "application/x-authorware-bin" }, + { "vrml", "x-world/x-vrml" }, + { "vsd", "application/vnd.visio" }, + { "vsf", "application/vnd.vsf" }, + { "vss", "application/vnd.visio" }, + { "vst", "application/vnd.visio" }, + { "vsw", "application/vnd.visio" }, + { "vtu", "model/vnd.vtu" }, + { "vxml", "application/voicexml+xml" }, + { "w3d", "application/x-director" }, + { "wad", "application/x-doom" }, + { "wav", "audio/x-wav" }, + { "wax", "audio/x-ms-wax" }, + { "wbmp", "image/vnd.wap.wbmp" }, + { "wbs", "application/vnd.criticaltools.wbs+xml" }, + { "wbxml", "application/vnd.wap.wbxml" }, + { "wcm", "application/vnd.ms-works" }, + { "wdb", "application/vnd.ms-works" }, + { "wiz", "application/msword" }, + { "wks", "application/vnd.ms-works" }, + { "wma", "audio/x-ms-wma" }, + { "wmd", "application/x-ms-wmd" }, + { "wmf", "application/x-msmetafile" }, + { "wmlc", "application/vnd.wap.wmlc" }, + { "wmlsc", "application/vnd.wap.wmlscriptc" }, + { "wmls", "text/vnd.wap.wmlscript" }, + { "wml", "text/vnd.wap.wml" }, + { "wm", "video/x-ms-wm" }, + { "wmv", "video/x-ms-wmv" }, + { "wmx", "video/x-ms-wmx" }, + { "wmz", "application/x-ms-wmz" }, + { "wpd", "application/vnd.wordperfect" }, + { "wpl", "application/vnd.ms-wpl" }, + { "wps", "application/vnd.ms-works" }, + { "wqd", "application/vnd.wqd" }, + { "wri", "application/x-mswrite" }, + { "wrl", "x-world/x-vrml" }, + { "wrz", "x-world/x-vrml" }, + { "wsdl", "application/wsdl+xml" }, + { "wspolicy", "application/wspolicy+xml" }, + { "wtb", "application/vnd.webturbo" }, + { "wvx", "video/x-ms-wvx" }, + { "x32", "application/x-authorware-bin" }, + { "x3d", "application/vnd.hzn-3d-crossword" }, + { "xaf", "x-world/x-vrml" }, + { "xap", "application/x-silverlight-app" }, + { "xar", "application/vnd.xara" }, + { "xbap", "application/x-ms-xbap" }, + { "xbd", "application/vnd.fujixerox.docuworks.binder" }, + { "xbm", "image/x-xbitmap" }, + { "xdm", "application/vnd.syncml.dm+xml" }, + { "xdp", "application/vnd.adobe.xdp+xml" }, + { "xdw", "application/vnd.fujixerox.docuworks" }, + { "xenc", "application/xenc+xml" }, + { "xer", "application/patch-ops-error+xml" }, + { "xfdf", "application/vnd.adobe.xfdf" }, + { "xfdl", "application/vnd.xfdl" }, + { "xht", "application/xhtml+xml" }, + { "xhtml", "application/xhtml+xml" }, + { "xhvml", "application/xv+xml" }, + { "xif", "image/vnd.xiff" }, + { "xla", "application/vnd.ms-excel" }, + { "xlam", "application/vnd.ms-excel.addin.macroenabled.12" }, + { "xlb", "application/vnd.ms-excel" }, + { "xlc", "application/vnd.ms-excel" }, + { "xlm", "application/vnd.ms-excel" }, + { "xls", "application/vnd.ms-excel" }, + { "xlsb", "application/vnd.ms-excel.sheet.binary.macroenabled.12" }, + { "xlsm", "application/vnd.ms-excel.sheet.macroenabled.12" }, + { "xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }, + { "xlt", "application/vnd.ms-excel" }, + { "xltm", "application/vnd.ms-excel.template.macroenabled.12" }, + { "xltx", "application/vnd.openxmlformats-officedocument.spreadsheetml.template" }, + { "xlw", "application/vnd.ms-excel" }, + { "xml", "application/xml" }, + { "xo", "application/vnd.olpc-sugar" }, + { "xof", "x-world/x-vrml" }, + { "xop", "application/xop+xml" }, + { "xpdl", "application/xml" }, + { "xpi", "application/x-xpinstall" }, + { "xpm", "image/x-xpixmap" }, + { "xpr", "application/vnd.is-xpr" }, + { "xps", "application/vnd.ms-xpsdocument" }, + { "xpw", "application/vnd.intercon.formnet" }, + { "xpx", "application/vnd.intercon.formnet" }, + { "xsl", "application/xml" }, + { "xslt", "application/xslt+xml" }, + { "xsm", "application/vnd.syncml+xml" }, + { "xspf", "application/xspf+xml" }, + { "xul", "application/vnd.mozilla.xul+xml" }, + { "xvm", "application/xv+xml" }, + { "xvml", "application/xv+xml" }, + { "xwd", "image/x-xwindowdump" }, + { "xyz", "chemical/x-xyz" }, + { "z", "application/x-compress" }, + { "zaz", "application/vnd.zzazz.deck+xml" }, + { "zip", "application/zip" }, + { "zir", "application/vnd.zul" }, + { "zirz", "application/vnd.zul" }, + { "zmm", "application/vnd.handheld-entertainment+xml" } + }; + + public static boolean isDefaultMimeType(String mimeType) { + return isSameMimeType(mimeType, DEFAULT_ATTACHMENT_MIME_TYPE); + } + + public static String getMimeTypeByExtension(String filename) { + String returnedType = null; + String extension = null; + + if (filename != null && filename.lastIndexOf('.') != -1) { + extension = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(Locale.US); + returnedType = android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); + } + // If the MIME type set by the user's mailer is application/octet-stream, try to figure + // out whether there's a sane file type extension. + if (returnedType != null && !isSameMimeType(returnedType, DEFAULT_ATTACHMENT_MIME_TYPE)) { + return returnedType; + } else if (extension != null) { + for (String[] contentTypeMapEntry : MIME_TYPE_BY_EXTENSION_MAP) { + if (contentTypeMapEntry[0].equals(extension)) { + return contentTypeMapEntry[1]; + } + } + } + + return DEFAULT_ATTACHMENT_MIME_TYPE; + } + + public static String getExtensionByMimeType(@NotNull String mimeType) { + String lowerCaseMimeType = mimeType.toLowerCase(Locale.US); + for (String[] contentTypeMapEntry : MIME_TYPE_BY_EXTENSION_MAP) { + if (contentTypeMapEntry[1].equals(lowerCaseMimeType)) { + return contentTypeMapEntry[0]; + } + } + + return null; + } + + public static boolean isSupportedImageType(String mimeType) { + return isSameMimeType(mimeType, "image/jpeg") || isSameMimeType(mimeType, "image/png") || + isSameMimeType(mimeType, "image/gif") || isSameMimeType(mimeType, "image/webp"); + } + + public static boolean isSupportedImageExtension(String filename) { + String mimeType = getMimeTypeByExtension(filename); + return isSupportedImageType(mimeType); + } + + public static boolean isSameMimeType(String mimeType, String otherMimeType) { + return mimeType != null && mimeType.equalsIgnoreCase(otherMimeType); + } +} diff --git a/app/core/src/main/java/com/fsck/k9/helper/ReplyToParser.java b/app/core/src/main/java/com/fsck/k9/helper/ReplyToParser.java index 7cbb15e714f48e4bcd58c482dbbd3d065f951c10..2d53a81392df7b9501bca6149215bd9ccafab796 100644 --- a/app/core/src/main/java/com/fsck/k9/helper/ReplyToParser.java +++ b/app/core/src/main/java/com/fsck/k9/helper/ReplyToParser.java @@ -12,7 +12,6 @@ import com.fsck.k9.Account; import com.fsck.k9.mail.Address; import com.fsck.k9.mail.Message; import com.fsck.k9.mail.Message.RecipientType; -import com.fsck.k9.mail.internet.ListHeaders; public class ReplyToParser { diff --git a/app/core/src/main/java/com/fsck/k9/helper/UnsubscribeUri.kt b/app/core/src/main/java/com/fsck/k9/helper/UnsubscribeUri.kt new file mode 100644 index 0000000000000000000000000000000000000000..d335d2edd101774c91911e24a42541ccff130902 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/helper/UnsubscribeUri.kt @@ -0,0 +1,10 @@ +package com.fsck.k9.helper + +import android.net.Uri + +sealed interface UnsubscribeUri { + val uri: Uri +} + +data class MailtoUnsubscribeUri(override val uri: Uri) : UnsubscribeUri +data class HttpsUnsubscribeUri(override val uri: Uri) : UnsubscribeUri 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 index d81c9e16611ab7b3507d66392440b0b572e1c67a..831637708c60816293d6a86a109a3a11c7c14bb3 100644 --- a/app/core/src/main/java/com/fsck/k9/logging/LogFileWriter.kt +++ b/app/core/src/main/java/com/fsck/k9/logging/LogFileWriter.kt @@ -26,7 +26,7 @@ class LogcatLogFileWriter( private fun writeLogBlocking(contentUri: Uri) { Timber.v("Writing logcat output to content URI: %s", contentUri) - val outputStream = contentResolver.openOutputStream(contentUri) + val outputStream = contentResolver.openOutputStream(contentUri, "wt") ?: error("Error opening contentUri for writing") outputStream.use { diff --git a/app/core/src/main/java/com/fsck/k9/mailstore/AttachmentViewInfo.java b/app/core/src/main/java/com/fsck/k9/mailstore/AttachmentViewInfo.java index a3403094b28673759cf99cc12d12c40d62fad6cd..47a5fa81611e632310a3190ad240edbd3b06fa5b 100644 --- a/app/core/src/main/java/com/fsck/k9/mailstore/AttachmentViewInfo.java +++ b/app/core/src/main/java/com/fsck/k9/mailstore/AttachmentViewInfo.java @@ -3,8 +3,8 @@ package com.fsck.k9.mailstore; import android.net.Uri; +import com.fsck.k9.helper.MimeTypeUtil; import com.fsck.k9.mail.Part; -import com.fsck.k9.mail.internet.MimeUtility; public class AttachmentViewInfo { @@ -48,8 +48,8 @@ public class AttachmentViewInfo { return false; } - return MimeUtility.isSupportedImageType(mimeType) || ( - MimeUtility.isSameMimeType(MimeUtility.DEFAULT_ATTACHMENT_MIME_TYPE, mimeType) && - MimeUtility.isSupportedImageExtension(displayName)); + return MimeTypeUtil.isSupportedImageType(mimeType) || ( + MimeTypeUtil.isSameMimeType(MimeTypeUtil.DEFAULT_ATTACHMENT_MIME_TYPE, mimeType) && + MimeTypeUtil.isSupportedImageExtension(displayName)); } } 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 74e66529b86ee4407b6468dad3e222bcc0b18b6d..ed4e39093c4bb5ac5ea6c4ae7842560d4072ce4b 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 @@ -4,6 +4,7 @@ package com.fsck.k9.mailstore; import java.util.Collections; import java.util.List; +import com.fsck.k9.helper.UnsubscribeUri; import com.fsck.k9.mail.Body; import com.fsck.k9.mail.Message; import com.fsck.k9.mail.MessagingException; @@ -24,6 +25,7 @@ public class MessageViewInfo { public final List attachments; public final String extraText; public final List extraAttachments; + public final UnsubscribeUri preferredUnsubscribeUri; public MessageViewInfo( @@ -32,7 +34,8 @@ public class MessageViewInfo { String text, List attachments, CryptoResultAnnotation cryptoResultAnnotation, AttachmentResolver attachmentResolver, - String extraText, List extraAttachments) { + String extraText, List extraAttachments, + UnsubscribeUri preferredUnsubscribeUri) { this.message = message; this.isMessageIncomplete = isMessageIncomplete; this.rootPart = rootPart; @@ -44,13 +47,15 @@ public class MessageViewInfo { this.attachments = attachments; this.extraText = extraText; this.extraAttachments = extraAttachments; + this.preferredUnsubscribeUri = preferredUnsubscribeUri; } static MessageViewInfo createWithExtractedContent(Message message, Part rootPart, boolean isMessageIncomplete, - String text, List attachments, AttachmentResolver attachmentResolver) { + String text, List attachments, AttachmentResolver attachmentResolver, + UnsubscribeUri preferredUnsubscribeUri) { return new MessageViewInfo( message, isMessageIncomplete, rootPart, null, false, text, attachments, null, attachmentResolver, null, - Collections.emptyList()); + Collections.emptyList(), preferredUnsubscribeUri); } public static MessageViewInfo createWithErrorState(Message message, boolean isMessageIncomplete) { @@ -59,7 +64,7 @@ public class MessageViewInfo { Part emptyPart = new MimeBodyPart(emptyBody, "text/plain"); String subject = message.getSubject(); return new MessageViewInfo(message, isMessageIncomplete, emptyPart, subject, false, null, null, null, null, - null, null); + null, null, null); } catch (MessagingException e) { throw new AssertionError(e); } @@ -67,7 +72,7 @@ public class MessageViewInfo { public static MessageViewInfo createForMetadataOnly(Message message, boolean isMessageIncomplete) { String subject = message.getSubject(); - return new MessageViewInfo(message, isMessageIncomplete, null, subject, false, null, null, null, null, null, null); + return new MessageViewInfo(message, isMessageIncomplete, null, subject, false, null, null, null, null, null, null, null); } MessageViewInfo withCryptoData(CryptoResultAnnotation rootPartAnnotation, String extraViewableText, @@ -76,14 +81,15 @@ public class MessageViewInfo { message, isMessageIncomplete, rootPart, subject, isSubjectEncrypted, text, attachments, rootPartAnnotation, attachmentResolver, - extraViewableText, extraAttachmentInfos + extraViewableText, extraAttachmentInfos, + preferredUnsubscribeUri ); } MessageViewInfo withSubject(String subject, boolean isSubjectEncrypted) { return new MessageViewInfo( message, isMessageIncomplete, rootPart, subject, isSubjectEncrypted, text, attachments, - cryptoResultAnnotation, attachmentResolver, extraText, extraAttachments + cryptoResultAnnotation, attachmentResolver, extraText, extraAttachments, preferredUnsubscribeUri ); } } 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 dc18704e76527a50819bc51d12c3f1db6ebf8e0c..b98c801464f971a3e9f03cf643cd9f2525abc25e 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 @@ -12,6 +12,8 @@ import androidx.annotation.WorkerThread; import com.fsck.k9.CoreResourceProvider; import com.fsck.k9.crypto.MessageCryptoStructureDetector; +import com.fsck.k9.helper.ListUnsubscribeHelper; +import com.fsck.k9.helper.UnsubscribeUri; import com.fsck.k9.mail.Address; import com.fsck.k9.mail.Flag; import com.fsck.k9.mail.Message; @@ -144,8 +146,11 @@ public class MessageViewInfoExtractor { boolean isMessageIncomplete = !message.isSet(Flag.X_DOWNLOADED_FULL) || MessageExtractor.hasMissingParts(message); + UnsubscribeUri preferredUnsubscribeUri = ListUnsubscribeHelper.INSTANCE.getPreferredListUnsubscribeUri(message); + return MessageViewInfo.createWithExtractedContent( - message, contentPart, isMessageIncomplete, viewable.html, attachmentInfos, attachmentResolver); + message, contentPart, isMessageIncomplete, viewable.html, attachmentInfos, attachmentResolver, + preferredUnsubscribeUri); } private ViewableExtractedText extractViewableAndAttachments(List parts, diff --git a/app/core/src/main/java/com/fsck/k9/message/extractors/AttachmentInfoExtractor.java b/app/core/src/main/java/com/fsck/k9/message/extractors/AttachmentInfoExtractor.java index 3f47997ab53f699c0fb2989366c725b24ec2d117..c570b5a256a0024a1226bd66f1e65e27bab6ce69 100644 --- a/app/core/src/main/java/com/fsck/k9/message/extractors/AttachmentInfoExtractor.java +++ b/app/core/src/main/java/com/fsck/k9/message/extractors/AttachmentInfoExtractor.java @@ -12,6 +12,7 @@ import android.net.Uri; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import com.fsck.k9.helper.MimeTypeUtil; import timber.log.Timber; import androidx.annotation.WorkerThread; @@ -120,7 +121,7 @@ public class AttachmentInfoExtractor { if (name == null) { String extension = null; if (mimeType != null) { - extension = MimeUtility.getExtensionByMimeType(mimeType); + extension = MimeTypeUtil.getExtensionByMimeType(mimeType); } name = "noname" + ((extension != null) ? "." + extension : ""); } 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 6992a87a4a1bd344786e1e913afb5fd74f76f898..9643a06f312dfb34c27ef799ac53175d35a6cf85 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 @@ -1,8 +1,8 @@ package com.fsck.k9.message.extractors +import com.fsck.k9.helper.MimeTypeUtil 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 private const val FALLBACK_NAME = "noname" @@ -31,7 +31,7 @@ class BasicPartInfoExtractor { } private fun String?.toDisplayName(): String { - val extension = this?.let { mimeType -> MimeUtility.getExtensionByMimeType(mimeType) } + val extension = this?.let { mimeType -> MimeTypeUtil.getExtensionByMimeType(mimeType) } return if (extension.isNullOrEmpty()) FALLBACK_NAME else "$FALLBACK_NAME.$extension" } diff --git a/app/core/src/main/java/com/fsck/k9/notification/NotificationIds.kt b/app/core/src/main/java/com/fsck/k9/notification/NotificationIds.kt index a074c61774b8d3299f890213de8c5e9d9ea7895a..d041c528fea3564871e3a1623623962de6057e56 100644 --- a/app/core/src/main/java/com/fsck/k9/notification/NotificationIds.kt +++ b/app/core/src/main/java/com/fsck/k9/notification/NotificationIds.kt @@ -30,8 +30,9 @@ internal object NotificationIds { } fun getAllMessageNotificationIds(account: Account): List { - val singleMessageNotificationIdRange = (0 until NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS) + - (getBaseNotificationId(account) + OFFSET_NEW_MAIL_SINGLE) + val singleMessageNotificationIdRange = (0 until NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS).map { index -> + getBaseNotificationId(account) + OFFSET_NEW_MAIL_SINGLE + index + } return singleMessageNotificationIdRange.toList() + getNewMailSummaryNotificationId(account) } diff --git a/app/core/src/main/java/com/fsck/k9/oauth/OAuthConfiguration.kt b/app/core/src/main/java/com/fsck/k9/oauth/OAuthConfiguration.kt new file mode 100644 index 0000000000000000000000000000000000000000..d3a11ed18c75f5faf31c2947e01ae9b382c0e4b5 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/oauth/OAuthConfiguration.kt @@ -0,0 +1,9 @@ +package com.fsck.k9.oauth + +data class OAuthConfiguration( + val clientId: String, + val scopes: List, + val authorizationEndpoint: String, + val tokenEndpoint: String, + val redirectUri: String +) diff --git a/app/core/src/main/java/com/fsck/k9/oauth/OAuthConfigurationProvider.kt b/app/core/src/main/java/com/fsck/k9/oauth/OAuthConfigurationProvider.kt new file mode 100644 index 0000000000000000000000000000000000000000..f2b8b31897ee60e647672d9ad1ed629846c9cbd4 --- /dev/null +++ b/app/core/src/main/java/com/fsck/k9/oauth/OAuthConfigurationProvider.kt @@ -0,0 +1,22 @@ +package com.fsck.k9.oauth + +class OAuthConfigurationProvider( + private val configurations: Map, OAuthConfiguration>, + private val googleConfiguration: OAuthConfiguration +) { + private val hostnameMapping: Map = buildMap { + for ((hostnames, configuration) in configurations) { + for (hostname in hostnames) { + put(hostname.lowercase(), configuration) + } + } + } + + fun getConfiguration(hostname: String): OAuthConfiguration? { + return hostnameMapping[hostname.lowercase()] + } + + fun isGoogle(hostname: String): Boolean { + return getConfiguration(hostname) == googleConfiguration + } +} 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 5ae56c7e1d235458c7a47d2cc9d204193821e60a..4037932c770dcb9d0fc0bd4717aeeb4936fb1383 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 @@ -13,4 +13,5 @@ interface AccountManager { fun moveAccount(account: Account, newPosition: Int) fun addOnAccountsChangeListener(accountsChangeListener: AccountsChangeListener) fun removeOnAccountsChangeListener(accountsChangeListener: AccountsChangeListener) + fun saveAccount(account: Account) } 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 bb939fe095768711d7f0aa7ad29ce8b82566bc95..519b014da488fe1601e7f9285f9955be146f9c06 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 @@ -207,13 +207,16 @@ public class AccountSettingsDescriptions { new V(53, new StringSetting(null)) )); s.put("useCompression.MOBILE", Settings.versions( - new V(1, new BooleanSetting(true)) + new V(1, new BooleanSetting(true)), + new V(81, null) )); s.put("useCompression.OTHER", Settings.versions( - new V(1, new BooleanSetting(true)) + new V(1, new BooleanSetting(true)), + new V(81, null) )); s.put("useCompression.WIFI", Settings.versions( - new V(1, new BooleanSetting(true)) + new V(1, new BooleanSetting(true)), + new V(81, null) )); s.put("vibrate", Settings.versions( new V(1, new BooleanSetting(false)) @@ -270,6 +273,9 @@ public class AccountSettingsDescriptions { s.put("notificationLight", Settings.versions( new V(80, new EnumSetting<>(NotificationLight.class, NotificationLight.Disabled)) )); + s.put("useCompression", Settings.versions( + new V(81, new BooleanSetting(true)) + )); // note that there is no setting for openPgpProvider, because this will have to be set up together // with the actual provider after import anyways. @@ -280,6 +286,7 @@ public class AccountSettingsDescriptions { u.put(54, new SettingsUpgraderV54()); u.put(74, new SettingsUpgraderV74()); u.put(80, new SettingsUpgraderV80()); + u.put(81, new SettingsUpgraderV81()); UPGRADERS = Collections.unmodifiableMap(u); } @@ -546,4 +553,22 @@ public class AccountSettingsDescriptions { return SetsKt.setOf("led", "ledColor"); } } + + /** + * Rewrite the per-network type IMAP compression settings to a single setting. + */ + private static class SettingsUpgraderV81 implements SettingsUpgrader { + @Override + public Set upgrade(Map settings) { + Boolean useCompressionWifi = (Boolean) settings.get("useCompression.WIFI"); + Boolean useCompressionMobile = (Boolean) settings.get("useCompression.MOBILE"); + Boolean useCompressionOther = (Boolean) settings.get("useCompression.OTHER"); + + boolean useCompression = useCompressionWifi != null && useCompressionMobile != null && + useCompressionOther != null && useCompressionWifi && useCompressionMobile && useCompressionOther; + settings.put("useCompression", useCompression); + + return SetsKt.setOf("useCompression.WIFI", "useCompression.MOBILE", "useCompression.OTHER"); + } + } } 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 8124a0c345ebdad258bd3cd9909bf13af8f892fc..db1674f7c4abd4bdc6e0dd336f3b9fa33ed21a73 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 = 80; + public static final int VERSION = 81; 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 39983b13918858999acb1173a9d59011af00dff6..08bc5bbefc060b85c70cfa54055c5990636febae 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 @@ -33,7 +33,7 @@ class SettingsExporter( updateNotificationSettings(accountUuids) try { - contentResolver.openOutputStream(uri)!!.use { outputStream -> + contentResolver.openOutputStream(uri, "wt")!!.use { outputStream -> exportPreferences(outputStream, includeGlobals, accountUuids) } } catch (e: Exception) { 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 661ab013cf5cdc4d31cb931d623d33a9b8a66e67..5e18598c3755327bfeffacbb024f5fa38b48b701 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 @@ -77,17 +77,19 @@ public class SettingsImporter { public final AccountDescription original; public final AccountDescription imported; public final boolean overwritten; + public final boolean authorizationNeeded; public final boolean incomingPasswordNeeded; public final boolean outgoingPasswordNeeded; public final String incomingServerName; public final String outgoingServerName; private AccountDescriptionPair(AccountDescription original, AccountDescription imported, - boolean overwritten, boolean incomingPasswordNeeded, boolean outgoingPasswordNeeded, - String incomingServerName, String outgoingServerName) { + boolean overwritten, boolean authorizationNeeded, boolean incomingPasswordNeeded, + boolean outgoingPasswordNeeded, String incomingServerName, String outgoingServerName) { this.original = original; this.imported = imported; this.overwritten = overwritten; + this.authorizationNeeded = authorizationNeeded; this.incomingPasswordNeeded = incomingPasswordNeeded; this.outgoingPasswordNeeded = outgoingPasswordNeeded; this.incomingServerName = incomingServerName; @@ -372,8 +374,11 @@ public class SettingsImporter { String incomingServerName = incoming.host; boolean incomingPasswordNeeded = AuthType.EXTERNAL != incoming.authenticationType && + AuthType.XOAUTH2 != incoming.authenticationType && (incoming.password == null || incoming.password.isEmpty()); + boolean authorizationNeeded = incoming.authenticationType == AuthType.XOAUTH2; + String incomingServerType = ServerTypeConverter.toServerSettingsType(account.incoming.type); if (account.outgoing == null && !incomingServerType.equals(Protocols.WEBDAV)) { // All account types except WebDAV need to provide outgoing server settings @@ -395,15 +400,18 @@ public class SettingsImporter { */ String outgoingServerType = ServerTypeConverter.toServerSettingsType(outgoing.type); outgoingPasswordNeeded = AuthType.EXTERNAL != outgoing.authenticationType && + AuthType.XOAUTH2 != outgoing.authenticationType && !outgoingServerType.equals(Protocols.WEBDAV) && outgoing.username != null && !outgoing.username.isEmpty() && (outgoing.password == null || outgoing.password.isEmpty()); + authorizationNeeded |= outgoing.authenticationType == AuthType.XOAUTH2; + outgoingServerName = outgoing.host; } - boolean createAccountDisabled = incomingPasswordNeeded || outgoingPasswordNeeded; + boolean createAccountDisabled = incomingPasswordNeeded || outgoingPasswordNeeded || authorizationNeeded; if (createAccountDisabled) { editor.putBoolean(accountKeyPrefix + "enabled", false); } @@ -465,7 +473,7 @@ public class SettingsImporter { putString(editor, accountKeyPrefix + "messagesNotificationChannelVersion", messageNotificationChannelVersion); AccountDescription imported = new AccountDescription(accountName, uuid); - return new AccountDescriptionPair(original, imported, mergeImportedAccount, + return new AccountDescriptionPair(original, imported, mergeImportedAccount, authorizationNeeded, incomingPasswordNeeded, outgoingPasswordNeeded, incomingServerName, outgoingServerName); } @@ -1061,11 +1069,12 @@ public class SettingsImporter { String type = ServerTypeConverter.toServerSettingsType(importedServer.type); int port = convertPort(importedServer.port); ConnectionSecurity connectionSecurity = convertConnectionSecurity(importedServer.connectionSecurity); + String password = importedServer.authenticationType == AuthType.XOAUTH2 ? "" : importedServer.password; Map extra = importedServer.extras != null ? unmodifiableMap(importedServer.extras.settings) : emptyMap(); return new ServerSettings(type, importedServer.host, port, connectionSecurity, - importedServer.authenticationType, importedServer.username, importedServer.password, + importedServer.authenticationType, importedServer.username, password, importedServer.clientCertificateAlias, extra); } diff --git a/app/core/src/main/java/com/fsck/k9/provider/AttachmentProvider.java b/app/core/src/main/java/com/fsck/k9/provider/AttachmentProvider.java index bee162ae0eac2609976edacd849612f7d4c16f27..21a91725b760ea0ba15956da26974525477e1364 100644 --- a/app/core/src/main/java/com/fsck/k9/provider/AttachmentProvider.java +++ b/app/core/src/main/java/com/fsck/k9/provider/AttachmentProvider.java @@ -15,13 +15,13 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.fsck.k9.DI; +import com.fsck.k9.helper.MimeTypeUtil; import com.fsck.k9.mailstore.LocalStoreProvider; import timber.log.Timber; import com.fsck.k9.Account; import com.fsck.k9.Preferences; import com.fsck.k9.mail.MessagingException; -import com.fsck.k9.mail.internet.MimeUtility; import com.fsck.k9.mailstore.LocalStore; import com.fsck.k9.mailstore.LocalStore.AttachmentInfo; import org.openintents.openpgp.util.OpenPgpApi.OpenPgpDataSource; @@ -156,7 +156,7 @@ public class AttachmentProvider extends ContentProvider { } } catch (MessagingException e) { Timber.e(e, "Unable to retrieve LocalStore for %s", account); - type = MimeUtility.DEFAULT_ATTACHMENT_MIME_TYPE; + type = MimeTypeUtil.DEFAULT_ATTACHMENT_MIME_TYPE; } return type; diff --git a/mail/common/src/test/java/com/fsck/k9/mail/internet/ListHeadersTest.java b/app/core/src/test/java/com/fsck/k9/helper/ListHeadersTest.java similarity index 95% rename from mail/common/src/test/java/com/fsck/k9/mail/internet/ListHeadersTest.java rename to app/core/src/test/java/com/fsck/k9/helper/ListHeadersTest.java index ba6bfbee31bf1f6d40a61f43c769d6eef49a8131..1f0fdfcd6eca00f4267be2412ba7bc37222e92f3 100644 --- a/mail/common/src/test/java/com/fsck/k9/mail/internet/ListHeadersTest.java +++ b/app/core/src/test/java/com/fsck/k9/helper/ListHeadersTest.java @@ -1,18 +1,17 @@ -package com.fsck.k9.mail.internet; +package com.fsck.k9.helper; +import com.fsck.k9.RobolectricTest; import com.fsck.k9.mail.Address; -import com.fsck.k9.mail.K9LibRobolectricTestRunner; import com.fsck.k9.mail.Message; +import com.fsck.k9.mail.internet.MimeMessage; import org.junit.Test; -import org.junit.runner.RunWith; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; -@RunWith(K9LibRobolectricTestRunner.class) -public class ListHeadersTest { +public class ListHeadersTest extends RobolectricTest { private static final String[] TEST_EMAIL_ADDRESSES = new String[] { "prettyandsimple@example.com", "very.common@example.com", diff --git a/app/core/src/test/java/com/fsck/k9/helper/ListUnsubscribeHelperTest.kt b/app/core/src/test/java/com/fsck/k9/helper/ListUnsubscribeHelperTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..4c327aefe1a08526a7b914140ebdc6d7ff265203 --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/helper/ListUnsubscribeHelperTest.kt @@ -0,0 +1,95 @@ +package com.fsck.k9.helper + +import androidx.core.net.toUri +import com.fsck.k9.RobolectricTest +import com.fsck.k9.mail.internet.MimeMessage +import com.google.common.truth.Truth.assertThat +import kotlin.test.assertNull +import org.junit.Test + +class ListUnsubscribeHelperTest : RobolectricTest() { + @Test + fun `get list unsubscribe url - should accept mailto`() { + val message = buildMimeMessageWithListUnsubscribeValue( + "" + ) + val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message) + assertThat(result).isEqualTo(MailtoUnsubscribeUri("mailto:unsubscribe@example.com".toUri())) + } + + @Test + fun `get list unsubscribe url - should prefer mailto 1`() { + val message = buildMimeMessageWithListUnsubscribeValue( + ", " + ) + val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message) + assertThat(result).isEqualTo(MailtoUnsubscribeUri("mailto:unsubscribe@example.com".toUri())) + } + + @Test + fun `get list unsubscribe url - should prefer mailto 2`() { + val message = buildMimeMessageWithListUnsubscribeValue( + ", " + ) + val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message) + assertThat(result).isEqualTo(MailtoUnsubscribeUri("mailto:unsubscribe@example.com".toUri())) + } + + @Test + fun `get list unsubscribe url - should allow https if no mailto`() { + val message = buildMimeMessageWithListUnsubscribeValue( + "" + ) + val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message) + assertThat(result).isEqualTo(HttpsUnsubscribeUri("https://example.com/unsubscribe".toUri())) + } + + @Test + fun `get list unsubscribe url - should correctly parse uncommon urls`() { + val message = buildMimeMessageWithListUnsubscribeValue( + "" + ) + val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message) + assertThat(result).isEqualTo(HttpsUnsubscribeUri("https://domain.example/one,two".toUri())) + } + + @Test + fun `get list unsubscribe url - should ignore unsafe entries 1`() { + val message = buildMimeMessageWithListUnsubscribeValue( + "" + ) + val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message) + assertNull(result) + } + + @Test + fun `get list unsubscribe url - should ignore unsafe entries 2`() { + val message = buildMimeMessageWithListUnsubscribeValue( + ", " + ) + val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message) + assertThat(result).isEqualTo(HttpsUnsubscribeUri("https://example.com/unsubscribe".toUri())) + } + + @Test + fun `get list unsubscribe url - should ignore empty`() { + val message = buildMimeMessageWithListUnsubscribeValue( + "" + ) + val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message) + assertNull(result) + } + + @Test + fun `get list unsubscribe url - should ignore missing header`() { + val message = MimeMessage() + val result = ListUnsubscribeHelper.getPreferredListUnsubscribeUri(message) + assertNull(result) + } + + private fun buildMimeMessageWithListUnsubscribeValue(value: String): MimeMessage { + val message = MimeMessage() + message.addHeader("List-Unsubscribe", value) + return message + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/helper/MailToTest.java b/app/core/src/test/java/com/fsck/k9/helper/MailToTest.java similarity index 97% rename from mail/common/src/test/java/com/fsck/k9/helper/MailToTest.java rename to app/core/src/test/java/com/fsck/k9/helper/MailToTest.java index bca04c1c44b875fb71ae1ffeb4ce980f4e7bb2f0..d388d4136d5716ce093e802f3e88612f90617ef4 100644 --- a/mail/common/src/test/java/com/fsck/k9/helper/MailToTest.java +++ b/app/core/src/test/java/com/fsck/k9/helper/MailToTest.java @@ -1,20 +1,18 @@ package com.fsck.k9.helper; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + import android.net.Uri; +import com.fsck.k9.RobolectricTest; import com.fsck.k9.helper.MailTo.CaseInsensitiveParamWrapper; import com.fsck.k9.mail.Address; -import com.fsck.k9.mail.K9LibRobolectricTestRunner; - import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; -import org.junit.runner.RunWith; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.assertFalse; @@ -23,8 +21,7 @@ import static org.hamcrest.CoreMatchers.is; import static org.junit.Assert.assertThat; -@RunWith(K9LibRobolectricTestRunner.class) -public class MailToTest { +public class MailToTest extends RobolectricTest { @Rule public ExpectedException exception = ExpectedException.none(); @@ -235,4 +232,4 @@ public class MailToTest { } -} \ No newline at end of file +} 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 501b2731b965eb759a871ff1604915109a80719d..6093ed15fdc6b0536d8ec36a0ae8b41e00513399 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 @@ -10,7 +10,6 @@ import com.fsck.k9.helper.ReplyToParser.ReplyToAddresses; import com.fsck.k9.mail.Address; import com.fsck.k9.mail.Message; import com.fsck.k9.mail.Message.RecipientType; -import com.fsck.k9.mail.internet.ListHeaders; import org.junit.Before; import org.junit.Test; 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 index 2fcc6e93ff9ebc590d804dd821a2a79ef2a1b473..a4229bbb0d8fbd174172cc0efc5f9498c1ee16df 100644 --- a/app/core/src/test/java/com/fsck/k9/logging/LogcatLogFileWriterTest.kt +++ b/app/core/src/test/java/com/fsck/k9/logging/LogcatLogFileWriterTest.kt @@ -57,13 +57,13 @@ class LogcatLogFileWriterTest { private fun createContentResolver(): ContentResolver { return mock { - on { openOutputStream(contentUri) } doReturn outputStream + on { openOutputStream(contentUri, "wt") } doReturn outputStream } } private fun createThrowingContentResolver(exception: Exception): ContentResolver { return mock { - on { openOutputStream(contentUri) } doAnswer { throw exception } + on { openOutputStream(contentUri, "wt") } doAnswer { throw exception } } } 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 9c1946b573f1f1e4d344f1f25b1f7a3ac40cdbdb..9c326bc52246e53a681fe8782596b0054160625a 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 @@ -81,11 +81,22 @@ class NotificationIdsTest { assertThat(maxNotificationId1 + 1).isEqualTo(minNotificationId2) } - fun getGeneralNotificationIds(): List { + @Test + fun `all message notification IDs`() { + val account = createAccount(1) + + val notificationIds = NotificationIds.getAllMessageNotificationIds(account) + + assertThat(notificationIds).containsExactlyElementsIn( + getNewMessageNotificationIds(account) + NotificationIds.getNewMailSummaryNotificationId(account) + ) + } + + private fun getGeneralNotificationIds(): List { return listOf(NotificationIds.PUSH_NOTIFICATION_ID) } - fun getAccountNotificationIds(account: Account): List { + private fun getAccountNotificationIds(account: Account): List { return listOf( NotificationIds.getSendFailedNotificationId(account), NotificationIds.getCertificateErrorNotificationId(account, true), @@ -94,7 +105,11 @@ class NotificationIdsTest { NotificationIds.getAuthenticationErrorNotificationId(account, false), NotificationIds.getFetchingMailNotificationId(account), NotificationIds.getNewMailSummaryNotificationId(account), - ) + (0 until MAX_NUMBER_OF_NEW_MESSAGE_NOTIFICATIONS).map { index -> + ) + getNewMessageNotificationIds(account) + } + + private fun getNewMessageNotificationIds(account: Account): List { + return (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/sasl/OAuthBearerTest.kt b/app/core/src/test/java/com/fsck/k9/sasl/OAuthBearerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..ff3bc7204da5f44df8c75ec3284d5d2191880aba --- /dev/null +++ b/app/core/src/test/java/com/fsck/k9/sasl/OAuthBearerTest.kt @@ -0,0 +1,31 @@ +package com.fsck.k9.sasl + +import com.google.common.truth.Truth.assertThat +import okio.ByteString.Companion.decodeBase64 +import org.junit.Test + +class OAuthBearerTest { + @Test + fun `username that does not need encoding`() { + val username = "user@domain.example" + val token = "token" + + val result = buildOAuthBearerInitialClientResponse(username, token) + + assertThat(result).isEqualTo("bixhPXVzZXJAZG9tYWluLmV4YW1wbGUsAWF1dGg9QmVhcmVyIHRva2VuAQE=") + assertThat(result.decodeBase64()?.utf8()) + .isEqualTo("n,a=user@domain.example,\u0001auth=Bearer token\u0001\u0001") + } + + @Test + fun `username contains equal sign that needs to be encoded`() { + val username = "user=name@domain.example" + val token = "token" + + val result = buildOAuthBearerInitialClientResponse(username, token) + + assertThat(result).isEqualTo("bixhPXVzZXI9M0RuYW1lQGRvbWFpbi5leGFtcGxlLAFhdXRoPUJlYXJlciB0b2tlbgEB") + assertThat(result.decodeBase64()?.utf8()) + .isEqualTo("n,a=user=3Dname@domain.example,\u0001auth=Bearer token\u0001\u0001") + } +} diff --git a/app/k9mail/build.gradle b/app/k9mail/build.gradle index 2daf4845b98fc41282dc07cefa05b7ae90450192..e55916d89a4eaf065353f5e29641f2aa305dcc54 100644 --- a/app/k9mail/build.gradle +++ b/app/k9mail/build.gradle @@ -48,8 +48,8 @@ android { applicationId "foundation.e.mail" testApplicationId "foundation.e.mail.tests" - versionCode 30000 - versionName '6.001' + versionCode 31000 + versionName '6.100' // 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", @@ -66,6 +66,12 @@ android { signingConfigs { release + debug { + keyAlias = "androiddebugkey" + keyPassword = "android" + storeFile = file("$rootProject.projectDir/debug.keystore") + storePassword = "android" + } } buildTypes { @@ -77,16 +83,26 @@ android { minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' - buildConfigField "boolean", "DEVELOPER_MODE", "false" + buildConfigField "String", "OAUTH_GMAIL_CLIENT_ID", "\"262622259280-hhmh92rhklkg2k1tjil69epo0o9a12jm.apps.googleusercontent.com\"" + buildConfigField "String", "OAUTH_YAHOO_CLIENT_ID", "\"dj0yJmk9aHNUb3d2MW5TQnpRJmQ9WVdrOWVYbHpaRWM0YkdnbWNHbzlNQT09JnM9Y29uc3VtZXJzZWNyZXQmc3Y9MCZ4PWIz\"" + buildConfigField "String", "OAUTH_AOL_CLIENT_ID", "\"dj0yJmk9dUNqYXZhYWxOYkdRJmQ9WVdrOU1YQnZVRFZoY1ZrbWNHbzlNQT09JnM9Y29uc3VtZXJzZWNyZXQmc3Y9MCZ4PWIw\"" + + manifestPlaceholders = ['appAuthRedirectScheme': 'foundation.e.mail'] } debug { applicationIdSuffix ".debug" testCoverageEnabled rootProject.testCoverage + signingConfig signingConfigs.debug + minifyEnabled false - buildConfigField "boolean", "DEVELOPER_MODE", "true" + buildConfigField "String", "OAUTH_GMAIL_CLIENT_ID", "\"262622259280-5qb3vtj68d5dtudmaif4g9vd3cpar8r3.apps.googleusercontent.com\"" + buildConfigField "String", "OAUTH_YAHOO_CLIENT_ID", "\"dj0yJmk9ejRCRU1ybmZjQlVBJmQ9WVdrOVVrZEViak4xYmxZbWNHbzlNQT09JnM9Y29uc3VtZXJzZWNyZXQmc3Y9MCZ4PTZj\"" + buildConfigField "String", "OAUTH_AOL_CLIENT_ID", "\"dj0yJmk9cHYydkJkTUxHcXlYJmQ9WVdrOWVHZHhVVXN4VVV3bWNHbzlNQT09JnM9Y29uc3VtZXJzZWNyZXQmc3Y9MCZ4PTdm\"" + + manifestPlaceholders = ['appAuthRedirectScheme': 'foundation.e.mail.debug'] } } diff --git a/app/k9mail/src/main/AndroidManifest.xml b/app/k9mail/src/main/AndroidManifest.xml index 494ee2619ebc3b09d36825b45a769d3e48848139..575306f922f42d9c7c7989c82b9d2a75223af114 100644 --- a/app/k9mail/src/main/AndroidManifest.xml +++ b/app/k9mail/src/main/AndroidManifest.xml @@ -304,6 +304,10 @@ + + () } single { OpenPgpEncryptionExtractor.newInstance() } single { K9StoragePersister(get()) } + single { createOAuthConfigurationProvider() } } val appModules = listOf( @@ -39,6 +40,5 @@ val appModules = listOf( notificationModule, resourcesModule, backendsModule, - storageModule, - oauth2Module + storageModule ) diff --git a/app/k9mail/src/main/java/com/fsck/k9/auth/OAuthConfigurations.kt b/app/k9mail/src/main/java/com/fsck/k9/auth/OAuthConfigurations.kt new file mode 100644 index 0000000000000000000000000000000000000000..5d69afece3b09d928985fb3a373cf7cf33978aa0 --- /dev/null +++ b/app/k9mail/src/main/java/com/fsck/k9/auth/OAuthConfigurations.kt @@ -0,0 +1,39 @@ +package com.fsck.k9.auth + +import com.fsck.k9.BuildConfig +import com.fsck.k9.oauth.OAuthConfiguration +import com.fsck.k9.oauth.OAuthConfigurationProvider + +fun createOAuthConfigurationProvider(): OAuthConfigurationProvider { + val redirectUriSlash = BuildConfig.APPLICATION_ID + ":/oauth2redirect" + val redirectUriDoubleSlash = BuildConfig.APPLICATION_ID + "://oauth2redirect" + + val googleConfig = OAuthConfiguration( + clientId = BuildConfig.OAUTH_GMAIL_CLIENT_ID, + scopes = listOf("https://mail.google.com/"), + authorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth", + tokenEndpoint = "https://oauth2.googleapis.com/token", + redirectUri = redirectUriSlash + ) + + return OAuthConfigurationProvider( + configurations = mapOf( + listOf("imap.gmail.com", "imap.googlemail.com", "smtp.gmail.com", "smtp.googlemail.com") to googleConfig, + listOf("imap.mail.yahoo.com", "smtp.mail.yahoo.com") to OAuthConfiguration( + clientId = BuildConfig.OAUTH_YAHOO_CLIENT_ID, + scopes = listOf("mail-w"), + authorizationEndpoint = "https://api.login.yahoo.com/oauth2/request_auth", + tokenEndpoint = "https://api.login.yahoo.com/oauth2/get_token", + redirectUri = redirectUriDoubleSlash + ), + listOf("imap.aol.com", "smtp.aol.com") to OAuthConfiguration( + clientId = BuildConfig.OAUTH_AOL_CLIENT_ID, + scopes = listOf("mail-w"), + authorizationEndpoint = "https://api.login.aol.com/oauth2/request_auth", + tokenEndpoint = "https://api.login.aol.com/oauth2/get_token", + redirectUri = redirectUriDoubleSlash + ), + ), + googleConfiguration = googleConfig + ) +} diff --git a/app/k9mail/src/main/java/com/fsck/k9/backends/ImapBackendFactory.kt b/app/k9mail/src/main/java/com/fsck/k9/backends/ImapBackendFactory.kt index c62a638d3ef6d22d0585342df10cc8099b38754a..2b14b1b0ffdaa751d3ad2c0900115b75bcd6cdcd 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/backends/ImapBackendFactory.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/backends/ImapBackendFactory.kt @@ -1,14 +1,12 @@ package com.fsck.k9.backends import android.content.Context -import android.net.ConnectivityManager import com.fsck.k9.Account import com.fsck.k9.backend.BackendFactory import com.fsck.k9.backend.api.Backend import com.fsck.k9.backend.imap.ImapBackend import com.fsck.k9.backend.imap.ImapPushConfigProvider -import com.fsck.k9.mail.NetworkType -import com.fsck.k9.mail.oauth.OAuth2TokenProvider +import com.fsck.k9.mail.AuthType import com.fsck.k9.mail.power.PowerManager import com.fsck.k9.mail.ssl.TrustedSocketFactory import com.fsck.k9.mail.store.imap.IdleRefreshManager @@ -22,13 +20,12 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map class ImapBackendFactory( - private val context: Context, private val accountManager: AccountManager, private val powerManager: PowerManager, private val idleRefreshManager: IdleRefreshManager, private val backendStorageFactory: K9BackendStorageFactory, private val trustedSocketFactory: TrustedSocketFactory, - private val oAuth2TokenProvider: OAuth2TokenProvider + private val context: Context ) : BackendFactory { override fun createBackend(account: Account): Backend { val accountName = account.displayName @@ -49,13 +46,17 @@ class ImapBackendFactory( } private fun createImapStore(account: Account): ImapStore { - val oAuth2TokenProvider: OAuth2TokenProvider? = oAuth2TokenProvider + val oAuth2TokenProvider = if (account.incomingServerSettings.authenticationType == AuthType.XOAUTH2) { + RealOAuth2TokenProvider(context, accountManager, account) + } else { + null + } + val config = createImapStoreConfig(account) return ImapStore.create( account.incomingServerSettings, config, trustedSocketFactory, - context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager, oAuth2TokenProvider ) } @@ -67,13 +68,18 @@ class ImapBackendFactory( override fun isSubscribedFoldersOnly() = account.isSubscribedFoldersOnly - override fun useCompression(type: NetworkType) = account.useCompression(type) + override fun useCompression() = account.useCompression } } private fun createSmtpTransport(account: Account): SmtpTransport { val serverSettings = account.outgoingServerSettings - val oauth2TokenProvider: OAuth2TokenProvider? = oAuth2TokenProvider + val oauth2TokenProvider = if (serverSettings.authenticationType == AuthType.XOAUTH2) { + RealOAuth2TokenProvider(context, accountManager, account) + } else { + null + } + return SmtpTransport(serverSettings, trustedSocketFactory, oauth2TokenProvider) } 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 1b947b8ded21e02b921ba3305c4967a5552a0914..b23177632c14a23eef1754d6fc761f93ec4f7260 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 @@ -5,7 +5,9 @@ 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 +import com.fsck.k9.helper.DefaultTrustedSocketFactory import com.fsck.k9.mail.store.imap.IdleRefreshManager +import com.fsck.k9.mail.store.webdav.SniHostSetter import org.koin.dsl.module val backendsModule = module { @@ -20,19 +22,30 @@ val backendsModule = module { } single { ImapBackendFactory( - context = get(), accountManager = get(), powerManager = get(), idleRefreshManager = get(), backendStorageFactory = get(), trustedSocketFactory = get(), - oAuth2TokenProvider = get(), + context = get() ) } single { AndroidAlarmManager(context = get(), alarmManager = get()) } single { BackendIdleRefreshManager(alarmManager = get()) } single { Pop3BackendFactory(get(), get()) } - single { WebDavBackendFactory(get(), get(), get()) } + single { + WebDavBackendFactory( + backendStorageFactory = get(), + trustManagerFactory = get(), + sniHostSetter = get(), + folderRepository = get() + ) + } + single { + SniHostSetter { factory, socket, hostname -> + DefaultTrustedSocketFactory.setSniHost(factory, socket, hostname) + } + } developmentModuleAdditions() } diff --git a/app/k9mail/src/main/java/com/fsck/k9/backends/RealOAuth2TokenProvider.kt b/app/k9mail/src/main/java/com/fsck/k9/backends/RealOAuth2TokenProvider.kt new file mode 100644 index 0000000000000000000000000000000000000000..b802d8cd6fb46534ead668d50dcd06422ef3f6d1 --- /dev/null +++ b/app/k9mail/src/main/java/com/fsck/k9/backends/RealOAuth2TokenProvider.kt @@ -0,0 +1,120 @@ +package com.fsck.k9.backends + +import android.content.Context +import android.os.Bundle +import android.os.Handler +import android.os.HandlerThread +import com.fsck.k9.Account +import com.fsck.k9.mail.AuthenticationFailedException +import com.fsck.k9.mail.oauth.OAuth2TokenProvider +import com.fsck.k9.preferences.AccountManager +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationService + +class RealOAuth2TokenProvider( + context: Context, + private val accountManager: AccountManager, + private val account: Account +) : OAuth2TokenProvider { + + companion object { + const val GOOGLE_ACCOUNT_TYPE = "e.foundation.webdav.google" + const val EELO_ACCOUNT_TYPE = "e.foundation.webdav.eelo" + const val ACCOUNT_EMAIL_ADDRESS_KEY = "email_address" + const val AUTH_TOKEN_TYPE = "oauth2-access-token" + } + + private val authService = AuthorizationService(context) + private var requestFreshToken = false + + private val osAccountManager by lazy { + android.accounts.AccountManager.get(context) + } + + override fun getToken(email: String, timeoutMillis: Long): String { + getTokenFromAccountManager(email)?.let { + return it + } + + val latch = CountDownLatch(1) + var token: String? = null + var exception: AuthorizationException? = null + + val authState = account.oAuthState?.let { AuthState.jsonDeserialize(it) } + ?: throw AuthenticationFailedException("Login required") + + if (requestFreshToken) { + authState.needsTokenRefresh = true + } + + val oldAccessToken = authState.accessToken + + authState.performActionWithFreshTokens(authService) { accessToken: String?, _, authException: AuthorizationException? -> + token = accessToken + exception = authException + + latch.countDown() + } + + latch.await(timeoutMillis, TimeUnit.MILLISECONDS) + + if (exception != null || token != oldAccessToken) { + requestFreshToken = false + account.oAuthState = authState.jsonSerializeString() + accountManager.saveAccount(account) + } + + exception?.let { authException -> + throw AuthenticationFailedException( + message = "Failed to fetch an access token", + throwable = authException, + messageFromServer = authException.error + ) + } + + return token ?: throw AuthenticationFailedException("Failed to fetch an access token") + } + + override fun invalidateToken() { + requestFreshToken = true + } + + private fun getTokenFromAccountManager(emailId: String): String? { + + var authToken: String? = null + val handlerThread = HandlerThread("callbackThread") + + handlerThread.start() + val handler = Handler(handlerThread.looper) + + val accounts: ArrayList = arrayListOf() + for (account in osAccountManager.getAccountsByType(GOOGLE_ACCOUNT_TYPE)) { + accounts.add(account) + } + + for (account in osAccountManager.getAccountsByType(EELO_ACCOUNT_TYPE)) { + accounts.add(account) + } + + for (account in accounts) { + val accountEmailId = osAccountManager.getUserData(account, ACCOUNT_EMAIL_ADDRESS_KEY) + if (emailId == accountEmailId) { + val latch = CountDownLatch(1) + osAccountManager.getAuthToken( + account, AUTH_TOKEN_TYPE, Bundle(), false, + { future -> + authToken = future?.result?.getString(android.accounts.AccountManager.KEY_AUTHTOKEN) + latch.countDown() + }, + handler + ) + latch.await() + } + } + + return authToken + } +} 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 0098b96e468d38b506cb0ea82f36c08c77af6d4b..0e4e588e335a1fcda65b1ef11ceeb42b106aff68 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 @@ -6,6 +6,7 @@ import com.fsck.k9.backend.api.Backend import com.fsck.k9.backend.webdav.WebDavBackend import com.fsck.k9.mail.ssl.TrustManagerFactory import com.fsck.k9.mail.store.webdav.DraftsFolderProvider +import com.fsck.k9.mail.store.webdav.SniHostSetter import com.fsck.k9.mail.store.webdav.WebDavStore import com.fsck.k9.mail.transport.WebDavTransport import com.fsck.k9.mailstore.FolderRepository @@ -14,6 +15,7 @@ import com.fsck.k9.mailstore.K9BackendStorageFactory class WebDavBackendFactory( private val backendStorageFactory: K9BackendStorageFactory, private val trustManagerFactory: TrustManagerFactory, + private val sniHostSetter: SniHostSetter, private val folderRepository: FolderRepository ) : BackendFactory { override fun createBackend(account: Account): Backend { @@ -21,8 +23,8 @@ class WebDavBackendFactory( val backendStorage = backendStorageFactory.createBackendStorage(account) val serverSettings = account.incomingServerSettings val draftsFolderProvider = createDraftsFolderProvider(account) - val webDavStore = WebDavStore(trustManagerFactory, serverSettings, draftsFolderProvider) - val webDavTransport = WebDavTransport(trustManagerFactory, serverSettings, draftsFolderProvider) + val webDavStore = WebDavStore(trustManagerFactory, sniHostSetter, serverSettings, draftsFolderProvider) + val webDavTransport = WebDavTransport(trustManagerFactory, sniHostSetter, serverSettings, draftsFolderProvider) return WebDavBackend(accountName, backendStorage, webDavStore, webDavTransport) } diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/K9StoragePersister.java b/app/storage/src/main/java/com/fsck/k9/preferences/K9StoragePersister.java index 0a0c290b80445bc0cbc5445c40087d6ddccaf430..6ea08aad686927841d9d1ab5c26965924d52135e 100644 --- a/app/storage/src/main/java/com/fsck/k9/preferences/K9StoragePersister.java +++ b/app/storage/src/main/java/com/fsck/k9/preferences/K9StoragePersister.java @@ -21,7 +21,7 @@ import timber.log.Timber; public class K9StoragePersister implements StoragePersister { - private static final int DB_VERSION = 17; + private static final int DB_VERSION = 18; private static final String DB_NAME = "preferences_storage"; private final Context context; diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo18.kt b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo18.kt new file mode 100644 index 0000000000000000000000000000000000000000..15a98f95217a4a2bb10c2bc885bfc646eafbacec --- /dev/null +++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrationTo18.kt @@ -0,0 +1,36 @@ +package com.fsck.k9.preferences.migrations + +import android.database.sqlite.SQLiteDatabase + +/** + * Rewrite the per-network type IMAP compression settings to a single setting. + */ +class StorageMigrationTo18( + private val db: SQLiteDatabase, + private val migrationsHelper: StorageMigrationsHelper +) { + fun rewriteImapCompressionSettings() { + val accountUuidsListValue = migrationsHelper.readValue(db, "accountUuids") + if (accountUuidsListValue == null || accountUuidsListValue.isEmpty()) { + return + } + + val accountUuids = accountUuidsListValue.split(",") + for (accountUuid in accountUuids) { + rewriteImapCompressionSetting(accountUuid) + } + } + + private fun rewriteImapCompressionSetting(accountUuid: String) { + val useCompressionWifi = migrationsHelper.readValue(db, "$accountUuid.useCompression.WIFI").toBoolean() + val useCompressionMobile = migrationsHelper.readValue(db, "$accountUuid.useCompression.MOBILE").toBoolean() + val useCompressionOther = migrationsHelper.readValue(db, "$accountUuid.useCompression.OTHER").toBoolean() + + val useCompression = useCompressionWifi && useCompressionMobile && useCompressionOther + migrationsHelper.writeValue(db, "$accountUuid.useCompression", useCompression.toString()) + + migrationsHelper.writeValue(db, "$accountUuid.useCompression.WIFI", null) + migrationsHelper.writeValue(db, "$accountUuid.useCompression.MOBILE", null) + migrationsHelper.writeValue(db, "$accountUuid.useCompression.OTHER", null) + } +} diff --git a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrations.kt b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrations.kt index 8f435b07a64eca4944d9c0b8c4ae239c70820cb6..07a6a091b8d1340a21f4a377fc6c300c0bd2fd9d 100644 --- a/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrations.kt +++ b/app/storage/src/main/java/com/fsck/k9/preferences/migrations/StorageMigrations.kt @@ -23,5 +23,6 @@ internal object StorageMigrations { if (oldVersion < 15) StorageMigrationTo15(db, migrationsHelper).rewriteIdleRefreshInterval() if (oldVersion < 16) StorageMigrationTo16(db, migrationsHelper).changeDefaultRegisteredNameColor() if (oldVersion < 17) StorageMigrationTo17(db, migrationsHelper).rewriteNotificationLightSettings() + if (oldVersion < 18) StorageMigrationTo18(db, migrationsHelper).rewriteImapCompressionSettings() } } diff --git a/app/ui/legacy/build.gradle b/app/ui/legacy/build.gradle index 7fb11f6c7cd2284021fb7234101672bdebf6e555..1403b14b51ae08b8e23a807b5751a1aa875261dc 100644 --- a/app/ui/legacy/build.gradle +++ b/app/ui/legacy/build.gradle @@ -40,6 +40,7 @@ dependencies { implementation "com.mikepenz:fastadapter-extensions-drag:${versions.fastAdapter}" implementation "com.mikepenz:fastadapter-extensions-utils:${versions.fastAdapter}" implementation 'de.hdodenhof:circleimageview:3.1.0' + api 'net.openid:appauth:0.11.1' implementation "commons-io:commons-io:${versions.commonsIo}" implementation "androidx.core:core-ktx:${versions.androidxCore}" @@ -79,6 +80,8 @@ android { defaultConfig { minSdkVersion buildConfig.minSdk targetSdkVersion buildConfig.robolectricSdk + + manifestPlaceholders = ['appAuthRedirectScheme': 'foundation.e.mail'] } buildTypes { diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/KoinModule.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/KoinModule.kt index 93734a006131fcdc92c73623d643ca2f2c590b02..f1f970380c8df68a3cf63d82a8f762eecad09742 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/KoinModule.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/KoinModule.kt @@ -1,7 +1,10 @@ package com.fsck.k9.activity +import com.fsck.k9.activity.setup.AuthViewModel +import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module val activityModule = module { - single { MessageLoaderHelperFactory(get(), get()) } + single { MessageLoaderHelperFactory(messageViewInfoExtractorFactory = get(), htmlSettingsProvider = get()) } + viewModel { AuthViewModel(application = get(), accountManager = get(), oAuthConfigurationProvider = get()) } } 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 3f668626e3f627f564bb680cef56f487e00e6a03..19c1aecf2e373e9df275a34ccca7aa6dc8defe55 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 @@ -1028,6 +1028,9 @@ open class MessageList : } else if (id == R.id.move_to_drafts) { messageViewFragment!!.onMoveToDrafts() return true + } else if (id == R.id.unsubscribe) { + messageViewFragment!!.onUnsubscribe() + return true } else if (id == R.id.show_headers) { startActivity(MessageSourceActivity.createLaunchIntent(this, messageViewFragment!!.messageReference)) return true @@ -1127,6 +1130,7 @@ open class MessageList : menu.findItem(R.id.refile).isVisible = false menu.findItem(R.id.toggle_unread).isVisible = false menu.findItem(R.id.toggle_message_view_theme).isVisible = false + menu.findItem(R.id.unsubscribe).isVisible = false menu.findItem(R.id.show_headers).isVisible = false } else { // hide prev/next buttons in split mode @@ -1209,6 +1213,8 @@ open class MessageList : if (messageViewFragment!!.isOutbox) { menu.findItem(R.id.move_to_drafts).isVisible = true } + + menu.findItem(R.id.unsubscribe).isVisible = messageViewFragment!!.canMessageBeUnsubscribed() } // Set visibility of menu items related to the message list diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/loader/AttachmentInfoLoader.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/loader/AttachmentInfoLoader.java index 16019086e1ffa4ab5e37614b8015113f346a9ee1..6750dd52b3ca111976b51273f4211d9e49da41e8 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/loader/AttachmentInfoLoader.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/loader/AttachmentInfoLoader.java @@ -9,6 +9,7 @@ import android.net.Uri; import android.provider.OpenableColumns; import androidx.loader.content.AsyncTaskLoader; +import com.fsck.k9.helper.MimeTypeUtil; import com.fsck.k9.message.Attachment.LoadingState; import timber.log.Timber; @@ -81,11 +82,11 @@ public class AttachmentInfoLoader extends AsyncTaskLoader { } if (usableContentType == null) { - usableContentType = MimeUtility.getMimeTypeByExtension(name); + usableContentType = MimeTypeUtil.getMimeTypeByExtension(name); } if (!sourceAttachment.allowMessageType && MimeUtility.isMessageType(usableContentType)) { - usableContentType = MimeUtility.DEFAULT_ATTACHMENT_MIME_TYPE; + usableContentType = MimeTypeUtil.DEFAULT_ATTACHMENT_MIME_TYPE; } if (size <= 0) { diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/misc/Attachment.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/misc/Attachment.java index e9fbce77735b60ed6af3806697b6346a4d149fe5..25a2b45d857fdf6322143b2c525fb6235bd0ebcd 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/misc/Attachment.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/misc/Attachment.java @@ -4,7 +4,7 @@ import android.net.Uri; import android.os.Parcel; import android.os.Parcelable; -import com.fsck.k9.mail.internet.MimeUtility; +import com.fsck.k9.helper.MimeTypeUtil; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -108,9 +108,9 @@ public class Attachment implements Parcelable, com.fsck.k9.message.Attachment { return false; } - return MimeUtility.isSupportedImageType(contentType) || ( - MimeUtility.isSameMimeType(MimeUtility.DEFAULT_ATTACHMENT_MIME_TYPE, contentType) && - MimeUtility.isSupportedImageExtension(filename)); + return MimeTypeUtil.isSupportedImageType(contentType) || ( + MimeTypeUtil.isSameMimeType(MimeTypeUtil.DEFAULT_ATTACHMENT_MIME_TYPE, contentType) && + MimeTypeUtil.isSupportedImageExtension(filename)); } private Attachment(Uri uri, LoadingState state, int loaderId, String contentType, boolean allowMessageType, 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 deleted file mode 100644 index b7c6dbf8fe64d20249755a643b1cea7b1a40df75..0000000000000000000000000000000000000000 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupBasics.java +++ /dev/null @@ -1,426 +0,0 @@ -/* - * Copyright ECORP SAS 2022 - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.fsck.k9.activity.setup; - - -import android.content.Context; -import android.content.Intent; -import android.os.Bundle; -import android.text.Editable; -import android.text.TextWatcher; -import android.view.View; -import android.view.View.OnClickListener; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.CheckBox; -import android.widget.CompoundButton; -import android.widget.CompoundButton.OnCheckedChangeListener; - -import androidx.annotation.Nullable; -import androidx.lifecycle.LifecycleOwner; -import com.fsck.k9.Account; -import com.fsck.k9.Core; -import com.fsck.k9.DI; -import com.fsck.k9.EmailAddressValidator; -import com.fsck.k9.Preferences; -import com.fsck.k9.account.AccountCreator; -import com.fsck.k9.activity.setup.AccountSetupCheckSettings.CheckDirection; -import com.fsck.k9.activity.setup.accountmanager.MailAutoConfigDiscoveryHelper; -import com.fsck.k9.autodiscovery.api.DiscoveredServerSettings; -import com.fsck.k9.autodiscovery.api.DiscoveryResults; -import com.fsck.k9.autodiscovery.api.DiscoveryTarget; -import com.fsck.k9.autodiscovery.providersxml.ProvidersXmlDiscovery; -import com.fsck.k9.helper.Utility; -import com.fsck.k9.mail.AuthType; -import com.fsck.k9.mail.ServerSettings; -import com.fsck.k9.mail.oauth.OAuth2Provider; -import com.fsck.k9.mailstore.SpecialLocalFoldersCreator; -import com.fsck.k9.ui.ConnectionSettings; -import com.fsck.k9.ui.R; -import com.fsck.k9.ui.base.K9Activity; -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; -import com.google.android.material.textfield.TextInputLayout; -import com.lamonjush.fullscreenloadingdialog.FullScreenLoadingDialog; -import timber.log.Timber; - -/** - * Prompts the user for the email address and password. - * Attempts to lookup default settings for the domain the user specified. If the - * domain is known the settings are handed off to the AccountSetupCheckSettings - * activity. If no settings are found the settings are handed off to the - * AccountSetupAccountType activity. - */ -public class AccountSetupBasics extends K9Activity - implements OnClickListener, TextWatcher, OnCheckedChangeListener, OnClientCertificateChangedListener { - private final static String EXTRA_ACCOUNT = "com.fsck.k9.AccountSetupBasics.account"; - private final static String STATE_KEY_CHECKED_INCOMING = "com.fsck.k9.AccountSetupBasics.checkedIncoming"; - - - private final ProvidersXmlDiscovery providersXmlDiscovery = DI.get(ProvidersXmlDiscovery.class); - private final AccountCreator accountCreator = DI.get(AccountCreator.class); - private final SpecialLocalFoldersCreator localFoldersCreator = DI.get(SpecialLocalFoldersCreator.class); - - private TextInputEditText mEmailView; - private TextInputEditText mPasswordView; - private TextInputLayout mPasswordLayoutView; - private CheckBox mClientCertificateCheckBox; - private ClientCertificateSpinner mClientCertificateSpinner; - private Button mNextButton; - private Button mManualSetupButton; - private View mAdvandedOptionsFoldable; - private Account mAccount; - private ViewGroup mAllowClientCertificateView; - - private EmailAddressValidator mEmailValidator = new EmailAddressValidator(); - private boolean mCheckedIncoming = false; - private boolean xoauth2; - - public static void actionNewAccount(Context context) { - Intent i = new Intent(context, AccountSetupBasics.class); - context.startActivity(i); - } - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setLayout(R.layout.account_setup_basics); - setTitle(R.string.account_setup_basics_title); - mEmailView = findViewById(R.id.account_email); - mPasswordView = findViewById(R.id.account_password); - mPasswordLayoutView = findViewById(R.id.account_password_input_layout); - mClientCertificateCheckBox = findViewById(R.id.account_client_certificate); - mClientCertificateSpinner = findViewById(R.id.account_client_certificate_spinner); - mAllowClientCertificateView = findViewById(R.id.account_allow_client_certificate); - - mNextButton = findViewById(R.id.next); - mManualSetupButton = findViewById(R.id.manual_setup); - mAdvandedOptionsFoldable = findViewById(R.id.foldable_advanced_options); - mNextButton.setOnClickListener(this); - mManualSetupButton.setOnClickListener(this); - } - - private void initializeViewListeners() { - mEmailView.addTextChangedListener(this); - mPasswordView.addTextChangedListener(this); - mClientCertificateCheckBox.setOnCheckedChangeListener(this); - mClientCertificateSpinner.setOnClientCertificateChangedListener(this); - } - - @Override - public void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - if (mAccount != null) { - outState.putString(EXTRA_ACCOUNT, mAccount.getUuid()); - } - outState.putBoolean(STATE_KEY_CHECKED_INCOMING, mCheckedIncoming); - } - - @Override - protected void onRestoreInstanceState(Bundle savedInstanceState) { - super.onRestoreInstanceState(savedInstanceState); - - if (savedInstanceState.containsKey(EXTRA_ACCOUNT)) { - String accountUuid = savedInstanceState.getString(EXTRA_ACCOUNT); - mAccount = Preferences.getPreferences(this).getAccount(accountUuid); - } - - mCheckedIncoming = savedInstanceState.getBoolean(STATE_KEY_CHECKED_INCOMING); - - updateViewVisibility(mClientCertificateCheckBox.isChecked()); - } - - @Override - protected void onPostCreate(Bundle savedInstanceState) { - super.onPostCreate(savedInstanceState); - - /* - * We wait until now to initialize the listeners because we didn't want - * the OnCheckedChangeListener active while the - * mClientCertificateCheckBox state was being restored because it could - * trigger the pop-up of a ClientCertificateSpinner.chooseCertificate() - * dialog. - */ - initializeViewListeners(); - validateFields(); - } - - public void afterTextChanged(Editable s) { - validateFields(); - } - - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - } - - public void onTextChanged(CharSequence s, int start, int before, int count) { - } - - @Override - public void onClientCertificateChanged(String alias) { - validateFields(); - } - - /** - * Called when checking the client certificate CheckBox - */ - @Override - public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { - updateViewVisibility(isChecked); - validateFields(); - - // Have the user select the client certificate if not already selected - if ((isChecked) && (mClientCertificateSpinner.getAlias() == null)) { - mClientCertificateSpinner.chooseCertificate(); - } - } - - private void updateViewVisibility(boolean usingCertificates) { - if (usingCertificates) { - // show client certificate spinner - mAllowClientCertificateView.setVisibility(View.VISIBLE); - } else { - // hide client certificate spinner - mAllowClientCertificateView.setVisibility(View.GONE); - } - } - - private void validateFields() { - boolean clientCertificateChecked = mClientCertificateCheckBox.isChecked(); - String clientCertificateAlias = mClientCertificateSpinner.getAlias(); - String email = mEmailView.getText().toString(); - - xoauth2 = false; - - if (email.contains("@")) { - String[] split = email.split("@"); - if (split.length == 2) { - String domain = split[1]; - xoauth2 = OAuth2Provider.Companion.isXOAuth2(domain); - - } - } - - boolean valid = Utility.requiredFieldValid(mEmailView) - && ((!clientCertificateChecked && (Utility.requiredFieldValid(mPasswordView) || xoauth2)) - || (clientCertificateChecked && clientCertificateAlias != null)) - && mEmailValidator.isValidAddressOnly(email); - - mNextButton.setEnabled(valid); - mNextButton.setFocusable(valid); - mManualSetupButton.setEnabled(valid); - - mPasswordLayoutView.setVisibility(xoauth2 ? View.GONE : View.VISIBLE); - mAdvandedOptionsFoldable.setVisibility(xoauth2 ? View.GONE : View.VISIBLE); - mManualSetupButton.setVisibility(xoauth2 ? View.GONE : View.VISIBLE); - - /* - * Dim the next button's icon to 50% if the button is disabled. - * TODO this can probably be done with a stateful drawable. Check into it. - * android:state_enabled - */ - Utility.setCompoundDrawablesAlpha(mNextButton, mNextButton.isEnabled() ? 255 : 128); - } - - private String getOwnerName() { - String name = null; - try { - name = getDefaultSenderName(); - } catch (Exception e) { - Timber.e(e, "Could not get default account name"); - } - - if (name == null) { - name = ""; - } - return name; - } - - private String getDefaultSenderName() { - String name = null; - Account account = Preferences.getPreferences(this).getDefaultAccount(); - if (account != null) { - name = account.getSenderName(); - } - return name; - } - - private void finishAutoSetup(ConnectionSettings connectionSettings) { - String email = mEmailView.getText().toString(); - String password = mPasswordView.getText().toString(); - - if (mAccount == null) { - mAccount = Preferences.getPreferences(this).newAccount(); - mAccount.setChipColor(accountCreator.pickColor()); - } - - mAccount.setSenderName(getOwnerName()); - mAccount.setEmail(email); - - ServerSettings incomingServerSettings = connectionSettings.getIncoming().newPassword(password); - mAccount.setIncomingServerSettings(incomingServerSettings); - - ServerSettings outgoingServerSettings = connectionSettings.getOutgoing().newPassword(password); - mAccount.setOutgoingServerSettings(outgoingServerSettings); - - mAccount.setDeletePolicy(accountCreator.getDefaultDeletePolicy(incomingServerSettings.type)); - - localFoldersCreator.createSpecialLocalFolders(mAccount); - - // Check incoming here. Then check outgoing in onActivityResult() - AccountSetupCheckSettings.actionCheckSettings(this, mAccount, CheckDirection.INCOMING); - } - - private ConnectionSettings providersXmlDiscoveryDiscover(String email, DiscoveryTarget discoveryTarget) { - DiscoveryResults discoveryResults = providersXmlDiscovery.discover(email, DiscoveryTarget.INCOMING_AND_OUTGOING); - if (discoveryResults == null || (discoveryResults.getIncoming().size() < 1 || discoveryResults.getOutgoing().size() < 1)) { - return null; - } - DiscoveredServerSettings incoming = discoveryResults.getIncoming().get(0); - DiscoveredServerSettings outgoing = discoveryResults.getOutgoing().get(0); - return new ConnectionSettings(new ServerSettings( - incoming.getProtocol(), - incoming.getHost(), - incoming.getPort(), - incoming.getSecurity(), - incoming.getAuthType(), - incoming.getUsername(), - null, - null - ), new ServerSettings( - outgoing.getProtocol(), - outgoing.getHost(), - outgoing.getPort(), - outgoing.getSecurity(), - outgoing.getAuthType(), - outgoing.getUsername(), - null, - null - )); - } - - private void onNext() { - if (mClientCertificateCheckBox.isChecked()) { - // Auto-setup doesn't support client certificates. - onManualSetup(); - return; - } - Editable emailEditable = mEmailView.getText(); - - if (emailEditable == null) { - return; - } - - String email = emailEditable.toString(); - - OAuth2Provider provider = OAuth2Provider.Companion.getProvider(email); - - if(provider != null && provider.toString().trim().equals("GMAIL")){ - Intent intent = new Intent(android.provider.Settings.ACTION_ADD_ACCOUNT); - startActivity(intent); - return; - } - - ConnectionSettings extraConnectionSettings = ExtraAccountDiscovery.discover(email); - if (extraConnectionSettings != null) { - finishAutoSetup(extraConnectionSettings); - return; - } - - ConnectionSettings connectionSettings = - providersXmlDiscoveryDiscover(email, DiscoveryTarget.INCOMING_AND_OUTGOING); - if (connectionSettings != null) { - finishAutoSetup(connectionSettings); - return; - } - // We don't have predefine configuration, try to retrieve it by api call - FullScreenLoadingDialog.getInstance() - .setSpinKitColor(R.color.color_default_accent) - .show(this); - MailAutoConfigDiscoveryHelper.INSTANCE.retrieveConfiguration(email, this, config -> { - FullScreenLoadingDialog.getInstance().dismiss(); - if (config != null) { - finishAutoSetup(config); - return; - } - // We can't find default settings for this account, start the manual setup process. - onManualSetup(); - }); - } - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - if (requestCode != AccountSetupCheckSettings.ACTIVITY_REQUEST_CODE) { - super.onActivityResult(requestCode, resultCode, data); - return; - } - - if (resultCode == RESULT_OK) { - if (!mCheckedIncoming) { - //We've successfully checked incoming. Now check outgoing. - mCheckedIncoming = true; - AccountSetupCheckSettings.actionCheckSettings(this, mAccount, CheckDirection.OUTGOING); - } else { - //We've successfully checked outgoing as well. - mAccount.setName(mAccount.getEmail()); - Preferences.getPreferences(this).saveAccount(mAccount); - Core.setServicesEnabled(this); - AccountSetupNames.actionSetNames(this, mAccount); - } - } - } - - private void onManualSetup() { - String email = mEmailView.getText().toString(); - - String password = null; - String clientCertificateAlias = null; - AuthType authenticationType; - - authenticationType = AuthType.PLAIN; - password = mPasswordView.getText().toString(); - if (mClientCertificateCheckBox.isChecked()) { - clientCertificateAlias = mClientCertificateSpinner.getAlias(); - if (mPasswordView.getText().toString().equals("")) { - authenticationType = AuthType.EXTERNAL; - password = null; - } - } - - if (mAccount == null) { - mAccount = Preferences.getPreferences(this).newAccount(); - mAccount.setChipColor(accountCreator.pickColor()); - } - mAccount.setSenderName(getOwnerName()); - mAccount.setEmail(email); - - InitialAccountSettings initialAccountSettings = new InitialAccountSettings(authenticationType, email, password, - clientCertificateAlias); - - AccountSetupAccountType.actionSelectAccountType(this, mAccount, false, initialAccountSettings); - } - - public void onClick(View v) { - int id = v.getId(); - if (id == R.id.next) { - onNext(); - } else if (id == R.id.manual_setup) { - onManualSetup(); - } - } -} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupBasics.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupBasics.kt new file mode 100644 index 0000000000000000000000000000000000000000..d1d3946c0f4d9c7ed0e6e02a84220fa607d6f0da --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupBasics.kt @@ -0,0 +1,428 @@ +package com.fsck.k9.activity.setup + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.provider.Settings +import android.text.Editable +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.CheckBox +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import com.fsck.k9.Account +import com.fsck.k9.Core +import com.fsck.k9.EmailAddressValidator +import com.fsck.k9.Preferences +import com.fsck.k9.account.AccountCreator +import com.fsck.k9.activity.setup.AccountSetupCheckSettings.CheckDirection +import com.fsck.k9.activity.setup.accountmanager.MailAutoConfigDiscovery +import com.fsck.k9.autodiscovery.api.DiscoveredServerSettings +import com.fsck.k9.autodiscovery.api.DiscoveryTarget +import com.fsck.k9.autodiscovery.providersxml.ProvidersXmlDiscovery +import com.fsck.k9.helper.SimpleTextWatcher +import com.fsck.k9.helper.Utility.requiredFieldValid +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mailstore.SpecialLocalFoldersCreator +import com.fsck.k9.oauth.OAuthConfigurationProvider +import com.fsck.k9.ui.ConnectionSettings +import com.fsck.k9.ui.R +import com.fsck.k9.ui.base.K9Activity +import com.fsck.k9.ui.getEnum +import com.fsck.k9.ui.putEnum +import com.fsck.k9.ui.settings.ExtraAccountDiscovery +import com.fsck.k9.view.ClientCertificateSpinner +import com.google.android.material.textfield.TextInputEditText +import com.lamonjush.fullscreenloadingdialog.FullScreenLoadingDialog +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject + +/** + * Prompts the user for the email address and password. + * + * Attempts to lookup default settings for the domain the user specified. If the domain is known, the settings are + * handed off to the [AccountSetupCheckSettings] activity. If no settings are found, the settings are handed off to the + * [AccountSetupAccountType] activity. + */ +class AccountSetupBasics : K9Activity() { + private val providersXmlDiscovery: ProvidersXmlDiscovery by inject() + private val accountCreator: AccountCreator by inject() + private val localFoldersCreator: SpecialLocalFoldersCreator by inject() + private val preferences: Preferences by inject() + private val emailValidator: EmailAddressValidator by inject() + + private val oAuthConfigurationProvider: OAuthConfigurationProvider by inject() + + private lateinit var emailView: TextInputEditText + private lateinit var passwordView: TextInputEditText + private lateinit var passwordLayout: View + private lateinit var clientCertificateCheckBox: CheckBox + private lateinit var clientCertificateSpinner: ClientCertificateSpinner + private lateinit var advancedOptionsContainer: View + private lateinit var nextButton: Button + private lateinit var manualSetupButton: Button + private lateinit var allowClientCertificateView: ViewGroup + + private var uiState = UiState.EMAIL_ADDRESS_ONLY + private var account: Account? = null + private var checkedIncoming = false + + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setLayout(R.layout.account_setup_basics) + setTitle(R.string.account_setup_basics_title) + + emailView = findViewById(R.id.account_email) + passwordView = findViewById(R.id.account_password) + passwordLayout = findViewById(R.id.account_password_layout) + clientCertificateCheckBox = findViewById(R.id.account_client_certificate) + clientCertificateSpinner = findViewById(R.id.account_client_certificate_spinner) + allowClientCertificateView = findViewById(R.id.account_allow_client_certificate) + advancedOptionsContainer = findViewById(R.id.foldable_advanced_options) + nextButton = findViewById(R.id.next) + manualSetupButton = findViewById(R.id.manual_setup) + + manualSetupButton.setOnClickListener { onManualSetup() } + } + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + + /* + * We wait until now to initialize the listeners because we didn't want the OnCheckedChangeListener active + * while the clientCertificateCheckBox state was being restored because it could trigger the pop-up of a + * ClientCertificateSpinner.chooseCertificate() dialog. + */ + initializeViewListeners() + validateFields() + + updateUi() + } + + private fun initializeViewListeners() { + val textWatcher = object : SimpleTextWatcher() { + override fun afterTextChanged(s: Editable) { + val checkPassword = uiState == UiState.PASSWORD_FLOW + validateFields(checkPassword) + } + } + + emailView.addTextChangedListener(textWatcher) + passwordView.addTextChangedListener(textWatcher) + + clientCertificateCheckBox.setOnCheckedChangeListener { _, isChecked -> + updateViewVisibility(isChecked) + validateFields() + + // Have the user select the client certificate if not already selected + if (isChecked && clientCertificateSpinner.alias == null) { + clientCertificateSpinner.chooseCertificate() + } + } + + clientCertificateSpinner.setOnClientCertificateChangedListener { + validateFields() + } + } + + private fun updateUi() { + when (uiState) { + UiState.EMAIL_ADDRESS_ONLY -> { + passwordLayout.isVisible = false + advancedOptionsContainer.isVisible = false + nextButton.setOnClickListener { attemptAutoSetupUsingOnlyEmailAddress() } + } + UiState.PASSWORD_FLOW -> { + passwordLayout.isVisible = true + advancedOptionsContainer.isVisible = true + nextButton.setOnClickListener { attemptAutoSetup() } + } + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + + outState.putEnum(STATE_KEY_UI_STATE, uiState) + outState.putString(EXTRA_ACCOUNT, account?.uuid) + outState.putBoolean(STATE_KEY_CHECKED_INCOMING, checkedIncoming) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + + uiState = savedInstanceState.getEnum(STATE_KEY_UI_STATE, UiState.EMAIL_ADDRESS_ONLY) + + val accountUuid = savedInstanceState.getString(EXTRA_ACCOUNT) + if (accountUuid != null) { + account = preferences.getAccount(accountUuid) + } + + checkedIncoming = savedInstanceState.getBoolean(STATE_KEY_CHECKED_INCOMING) + updateViewVisibility(clientCertificateCheckBox.isChecked) + } + + private fun updateViewVisibility(usingCertificates: Boolean) { + allowClientCertificateView.isVisible = usingCertificates + } + + private fun validateFields(checkPassword: Boolean = true) { + val email = emailView.text?.toString().orEmpty() + val valid = requiredFieldValid(emailView) && emailValidator.isValidAddressOnly(email) && + (!checkPassword || isPasswordFieldValid()) + + nextButton.isEnabled = valid + nextButton.isFocusable = valid + manualSetupButton.isEnabled = valid + } + + private fun isPasswordFieldValid(): Boolean { + val clientCertificateChecked = clientCertificateCheckBox.isChecked + val clientCertificateAlias = clientCertificateSpinner.alias + + return !clientCertificateChecked && requiredFieldValid(passwordView) || + clientCertificateChecked && clientCertificateAlias != null + } + + private fun attemptAutoSetupUsingOnlyEmailAddress() { + val email = emailView.text?.toString() ?: error("Email missing") + + val extraConnectionSettings = ExtraAccountDiscovery.discover(email) + if (extraConnectionSettings != null) { + finishAutoSetup(extraConnectionSettings) + return + } + + val connectionSettings = providersXmlDiscoveryDiscover(email) + + if (connectionSettings != null && + connectionSettings.incoming.authenticationType == AuthType.XOAUTH2 && + connectionSettings.outgoing.authenticationType == AuthType.XOAUTH2 + ) { + startOAuthFlow(connectionSettings) + } else { + startPasswordFlow() + } + } + + private fun startOAuthFlow(connectionSettings: ConnectionSettings) { + val account = createAccount(connectionSettings) + + if (oAuthConfigurationProvider.isGoogle(account.incomingServerSettings.host!!)) { + val intent = Intent(Settings.ACTION_ADD_ACCOUNT) + startActivity(intent) + return + } + + val intent = OAuthFlowActivity.buildLaunchIntent(this, account.uuid) + startActivityForResult(intent, REQUEST_CODE_OAUTH) + } + + private fun startPasswordFlow() { + uiState = UiState.PASSWORD_FLOW + + updateUi() + validateFields() + + passwordView.requestFocus() + } + + private fun attemptAutoSetup() { + if (clientCertificateCheckBox.isChecked) { + // Auto-setup doesn't support client certificates. + onManualSetup() + return + } + + val email = emailView.text?.toString() ?: error("Email missing") + + val extraConnectionSettings = ExtraAccountDiscovery.discover(email) + if (extraConnectionSettings != null) { + finishAutoSetup(extraConnectionSettings) + return + } + + val connectionSettings = providersXmlDiscoveryDiscover(email) + if (connectionSettings != null) { + finishAutoSetup(connectionSettings) + return + } + providersApiDiscoveryDiscover(email) + } + + private fun providersApiDiscoveryDiscover(email: String) { + // We don't have predefine configuration, try to retrieve it by api call + FullScreenLoadingDialog.getInstance() + .setSpinKitColor(R.color.color_default_accent) + .show(this) + + lifecycleScope.launch(Dispatchers.IO) { + val config = MailAutoConfigDiscovery.retrieveConfigFromApi(email) + lifecycleScope.launch(Dispatchers.Main) { + FullScreenLoadingDialog.getInstance().dismiss() + if (config != null) { + finishAutoSetup(config) + } else { + // We don't have default settings for this account, start the manual setup process. + onManualSetup() + } + } + } + } + + private fun finishAutoSetup(connectionSettings: ConnectionSettings) { + val account = createAccount(connectionSettings) + + // Check incoming here. Then check outgoing in onActivityResult() + AccountSetupCheckSettings.actionCheckSettings(this, account, CheckDirection.INCOMING) + } + + private fun createAccount(connectionSettings: ConnectionSettings): Account { + val email = emailView.text?.toString() ?: error("Email missing") + val password = passwordView.text?.toString() + + val account = initAccount(email) + + val incomingServerSettings = connectionSettings.incoming.newPassword(password) + account.incomingServerSettings = incomingServerSettings + + val outgoingServerSettings = connectionSettings.outgoing.newPassword(password) + account.outgoingServerSettings = outgoingServerSettings + + account.deletePolicy = accountCreator.getDefaultDeletePolicy(incomingServerSettings.type) + + localFoldersCreator.createSpecialLocalFolders(account) + + return account + } + + private fun onManualSetup() { + val email = emailView.text?.toString() ?: error("Email missing") + var password: String? = passwordView.text?.toString() + var clientCertificateAlias: String? = null + var authenticationType: AuthType = AuthType.PLAIN + + if (clientCertificateCheckBox.isChecked) { + clientCertificateAlias = clientCertificateSpinner.alias + if (password.isNullOrEmpty()) { + authenticationType = AuthType.EXTERNAL + password = null + } + } + + val account = initAccount(email) + + val initialAccountSettings = InitialAccountSettings( + authenticationType = authenticationType, + email = email, + password = password, + clientCertificateAlias = clientCertificateAlias + ) + + AccountSetupAccountType.actionSelectAccountType(this, account, makeDefault = false, initialAccountSettings) + } + + private fun initAccount(email: String): Account { + val account = this.account ?: createAccount().also { this.account = it } + + account.senderName = getOwnerName() + account.email = email + account.name = email + return account + } + + private fun createAccount(): Account { + return preferences.newAccount().apply { + chipColor = accountCreator.pickColor() + } + } + + private fun getOwnerName(): String { + return preferences.defaultAccount?.senderName ?: "" + } + + private fun providersXmlDiscoveryDiscover(email: String): ConnectionSettings? { + val discoveryResults = providersXmlDiscovery.discover(email, DiscoveryTarget.INCOMING_AND_OUTGOING) + if (discoveryResults == null || discoveryResults.incoming.isEmpty() || discoveryResults.outgoing.isEmpty()) { + return null + } + + val incomingServerSettings = discoveryResults.incoming.first().toServerSettings() ?: return null + val outgoingServerSettings = discoveryResults.outgoing.first().toServerSettings() ?: return null + + return ConnectionSettings(incomingServerSettings, outgoingServerSettings) + } + + public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + when (requestCode) { + REQUEST_CODE_CHECK_SETTINGS -> handleCheckSettingsResult(resultCode) + REQUEST_CODE_OAUTH -> handleSignInResult(resultCode) + else -> super.onActivityResult(requestCode, resultCode, data) + } + } + + private fun handleCheckSettingsResult(resultCode: Int) { + if (resultCode != RESULT_OK) return + + val account = this.account ?: error("Account instance missing") + + if (!checkedIncoming) { + // We've successfully checked incoming. Now check outgoing. + checkedIncoming = true + AccountSetupCheckSettings.actionCheckSettings(this, account, CheckDirection.OUTGOING) + } else { + // We've successfully checked outgoing as well. + preferences.saveAccount(account) + Core.setServicesEnabled(applicationContext) + + AccountSetupNames.actionSetNames(this, account) + } + } + + private fun handleSignInResult(resultCode: Int) { + if (resultCode != RESULT_OK) return + + val account = this.account ?: error("Account instance missing") + + AccountSetupCheckSettings.actionCheckSettings(this, account, CheckDirection.INCOMING) + } + + private enum class UiState { + EMAIL_ADDRESS_ONLY, + PASSWORD_FLOW + } + + companion object { + private const val EXTRA_ACCOUNT = "com.fsck.k9.AccountSetupBasics.account" + private const val STATE_KEY_UI_STATE = "com.fsck.k9.AccountSetupBasics.uiState" + private const val STATE_KEY_CHECKED_INCOMING = "com.fsck.k9.AccountSetupBasics.checkedIncoming" + private const val REQUEST_CODE_CHECK_SETTINGS = AccountSetupCheckSettings.ACTIVITY_REQUEST_CODE + private const val REQUEST_CODE_OAUTH = Activity.RESULT_FIRST_USER + 1 + + @JvmStatic + fun actionNewAccount(context: Context) { + val intent = Intent(context, AccountSetupBasics::class.java) + context.startActivity(intent) + } + } +} + +private fun DiscoveredServerSettings.toServerSettings(): ServerSettings? { + val authType = this.authType ?: return null + val username = this.username ?: return null + + return ServerSettings( + type = protocol, + host = host, + port = port, + connectionSecurity = security, + authenticationType = authType, + username = username, + password = null, + clientCertificateAlias = null + ) +} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupCheckSettings.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupCheckSettings.java deleted file mode 100644 index b8e2b1c173c9856b38fd988c26703392afb5b4b9..0000000000000000000000000000000000000000 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupCheckSettings.java +++ /dev/null @@ -1,621 +0,0 @@ - -package com.fsck.k9.activity.setup; - - -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.cert.CertificateEncodingException; -import java.security.cert.CertificateException; -import java.security.cert.X509Certificate; -import java.util.Collection; -import java.util.List; -import java.util.Locale; - -import android.app.Activity; -import android.app.AlertDialog; -import android.app.Dialog; -import android.content.DialogInterface; -import android.content.Intent; -import android.os.AsyncTask; -import android.os.Bundle; -import android.os.Handler; - -import android.util.Log; -import android.view.KeyEvent; -import android.view.View; -import android.view.View.OnClickListener; -import android.webkit.CookieManager; -import android.webkit.WebView; -import android.webkit.WebViewClient; -import android.widget.ProgressBar; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.fragment.app.DialogFragment; -import androidx.fragment.app.FragmentTransaction; -import com.fsck.k9.Account; -import com.fsck.k9.DI; -import com.fsck.k9.LocalKeyStoreManager; -import com.fsck.k9.Preferences; -import com.fsck.k9.mail.oauth.authorizationserver.codegrantflow.OAuth2NeedUserPromptException; -import com.fsck.k9.mail.oauth.authorizationserver.codegrantflow.OAuth2PromptRequestHandler; -import com.fsck.k9.mail.oauth.authorizationserver.codegrantflow.OAuth2CodeGrantFlowManager; -import com.fsck.k9.controller.MessagingController; -import com.fsck.k9.fragment.ConfirmationDialogFragment; -import com.fsck.k9.fragment.ConfirmationDialogFragment.ConfirmationDialogFragmentListener; -import com.fsck.k9.mail.AuthenticationFailedException; -import com.fsck.k9.mail.CertificateValidationException; -import com.fsck.k9.mail.MailServerDirection; -import com.fsck.k9.mail.MessagingException; -import com.fsck.k9.mail.filter.Hex; -import com.fsck.k9.preferences.Protocols; -import com.fsck.k9.ui.R; -import com.fsck.k9.ui.base.K9Activity; -import timber.log.Timber; - - -/** - * Checks the given settings to make sure that they can be used to send and - * receive mail. - * - * XXX NOTE: The manifest for this app has it ignore config changes, because - * it doesn't correctly deal with restarting while its thread is running. - */ -public class AccountSetupCheckSettings extends K9Activity implements OnClickListener, - ConfirmationDialogFragmentListener{ - - public static final int ACTIVITY_REQUEST_CODE = 1; - - private static final String EXTRA_ACCOUNT = "account"; - - private static final String EXTRA_CHECK_DIRECTION ="checkDirection"; - - private String className=this.getClass().getName(); - - public enum CheckDirection { - INCOMING, - OUTGOING; - - public MailServerDirection toMailServerDirection() { - switch (this) { - case INCOMING: return MailServerDirection.INCOMING; - case OUTGOING: return MailServerDirection.OUTGOING; - } - - throw new AssertionError("Unknown value: " + this); - } - } - - private final MessagingController messagingController = DI.get(MessagingController.class); - - private Handler mHandler = new Handler(); - - private ProgressBar mProgressBar; - - private TextView mMessageView; - - private Account mAccount; - - private CheckDirection mDirection; - - private boolean mCanceled; - - private boolean mDestroyed; - - private Dialog authDialog; - - private OAuth2CodeGrantFlowManager oAuth2CodeGrantFlowManager; - - public static void actionCheckSettings(Activity context, Account account, - CheckDirection direction) { - Intent i = new Intent(context, AccountSetupCheckSettings.class); - i.putExtra(EXTRA_ACCOUNT, account.getUuid()); - i.putExtra(EXTRA_CHECK_DIRECTION, direction); - context.startActivityForResult(i, ACTIVITY_REQUEST_CODE); - } - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setLayout(R.layout.account_setup_check_settings); - mMessageView = findViewById(R.id.message); - mProgressBar = findViewById(R.id.progress); - findViewById(R.id.cancel).setOnClickListener(this); - - setMessage(R.string.account_setup_check_settings_retr_info_msg); - mProgressBar.setIndeterminate(true); - - String accountUuid = getIntent().getStringExtra(EXTRA_ACCOUNT); - mAccount = Preferences.getPreferences(this).getAccount(accountUuid); - mDirection = (CheckDirection) getIntent().getSerializableExtra(EXTRA_CHECK_DIRECTION); - - oAuth2CodeGrantFlowManager = DI.get(OAuth2CodeGrantFlowManager.class); - oAuth2CodeGrantFlowManager.setPromptRequestHandler(promptRequestHandler); - - new CheckAccountTask(mAccount).execute(mDirection); - } - - private void handleCertificateValidationException(CertificateValidationException cve) { - Timber.e(cve, "Error while testing settings"); - - X509Certificate[] chain = cve.getCertChain(); - // Avoid NullPointerException in acceptKeyDialog() - if (chain != null) { - acceptKeyDialog( - R.string.account_setup_failed_dlg_certificate_message_fmt, - cve); - } else { - showErrorDialog( - R.string.account_setup_failed_dlg_server_message_fmt, - errorMessageForCertificateException(cve)); - } - } - - - @Override - public void onDestroy() { - super.onDestroy(); - oAuth2CodeGrantFlowManager.setPromptRequestHandler(null); - mDestroyed = true; - mCanceled = true; - } - - private void setMessage(final int resId) { - mMessageView.setText(getString(resId)); - } - - private void acceptKeyDialog(final int msgResId, final CertificateValidationException ex) { - mHandler.post(new Runnable() { - public void run() { - if (mDestroyed) { - return; - } - String exMessage = "Unknown Error"; - - if (ex != null) { - if (ex.getCause() != null) { - if (ex.getCause().getCause() != null) { - exMessage = ex.getCause().getCause().getMessage(); - - } else { - exMessage = ex.getCause().getMessage(); - } - } else { - exMessage = ex.getMessage(); - } - } - - mProgressBar.setIndeterminate(false); - StringBuilder chainInfo = new StringBuilder(200); - final X509Certificate[] chain = ex.getCertChain(); - // We already know chain != null (tested before calling this method) - for (int i = 0; i < chain.length; i++) { - // display certificate chain information - //TODO: localize this strings - chainInfo.append("Certificate chain[").append(i).append("]:\n"); - chainInfo.append("Subject: ").append(chain[i].getSubjectDN().toString()).append("\n"); - - // display SubjectAltNames too - // (the user may be mislead into mistrusting a certificate - // by a subjectDN not matching the server even though a - // SubjectAltName matches) - try { - final Collection < List> subjectAlternativeNames = chain[i].getSubjectAlternativeNames(); - if (subjectAlternativeNames != null) { - // The list of SubjectAltNames may be very long - //TODO: localize this string - StringBuilder altNamesText = new StringBuilder(); - altNamesText.append("Subject has ").append(subjectAlternativeNames.size()).append(" alternative names\n"); - - // we need these for matching - String incomingServerHost = mAccount.getIncomingServerSettings().host; - String outgoingServerHost = mAccount.getOutgoingServerSettings().host; - - for (List subjectAlternativeName : subjectAlternativeNames) { - Integer type = (Integer)subjectAlternativeName.get(0); - Object value = subjectAlternativeName.get(1); - String name; - switch (type) { - case 0: - Timber.w("SubjectAltName of type OtherName not supported."); - continue; - case 1: // RFC822Name - name = (String)value; - break; - case 2: // DNSName - name = (String)value; - break; - case 3: - Timber.w("unsupported SubjectAltName of type x400Address"); - continue; - case 4: - Timber.w("unsupported SubjectAltName of type directoryName"); - continue; - case 5: - Timber.w("unsupported SubjectAltName of type ediPartyName"); - continue; - case 6: // Uri - name = (String)value; - break; - case 7: // ip-address - name = (String)value; - break; - default: - Timber.w("unsupported SubjectAltName of unknown type"); - continue; - } - - // if some of the SubjectAltNames match the store or transport -host, - // display them - if (name.equalsIgnoreCase(incomingServerHost) || name.equalsIgnoreCase(outgoingServerHost)) { - //TODO: localize this string - altNamesText.append("Subject(alt): ").append(name).append(",...\n"); - } else if (name.startsWith("*.") && ( - incomingServerHost.endsWith(name.substring(2)) || - outgoingServerHost.endsWith(name.substring(2)))) { - //TODO: localize this string - altNamesText.append("Subject(alt): ").append(name).append(",...\n"); - } - } - chainInfo.append(altNamesText); - } - } catch (Exception e1) { - // don't fail just because of subjectAltNames - Timber.w(e1, "cannot display SubjectAltNames in dialog"); - } - - chainInfo.append("Issuer: ").append(chain[i].getIssuerDN().toString()).append("\n"); - String[] digestAlgorithms = new String[] {"SHA-1", "SHA-256", "SHA-512"}; - - for (String algorithm : digestAlgorithms) { - MessageDigest digest = null; - try { - digest = MessageDigest.getInstance(algorithm); - } catch (NoSuchAlgorithmException e) { - Timber.e(e, "Error while initializing MessageDigest (" + algorithm + ")"); - } - - if (digest != null) { - digest.reset(); - try { - String hash = Hex.encodeHex(digest.digest(chain[i].getEncoded())); - chainInfo.append("Fingerprint ("+ algorithm +"): ").append("\n").append(hash).append("\n"); - } catch (CertificateEncodingException e) { - Timber.e(e, "Error while encoding certificate"); - } - } - } - } - - // TODO: refactor with DialogFragment. - // This is difficult because we need to pass through chain[0] for onClick() - new AlertDialog.Builder(AccountSetupCheckSettings.this) - .setTitle(getString(R.string.account_setup_failed_dlg_invalid_certificate_title)) - //.setMessage(getString(R.string.account_setup_failed_dlg_invalid_certificate) - .setMessage(getString(msgResId, exMessage) - + " " + chainInfo.toString() - ) - .setCancelable(true) - .setPositiveButton( - getString(R.string.account_setup_failed_dlg_invalid_certificate_accept), - new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - acceptCertificate(chain[0]); - } - }) - .setNegativeButton( - getString(R.string.account_setup_failed_dlg_invalid_certificate_reject), - new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - finish(); - } - }) - .show(); - } - }); - } - - /** - * Permanently accepts a certificate for the INCOMING or OUTGOING direction - * by adding it to the local key store. - * - * @param certificate - */ - private void acceptCertificate(X509Certificate certificate) { - try { - DI.get(LocalKeyStoreManager.class).addCertificate(mAccount, mDirection.toMailServerDirection(), certificate); - } catch (CertificateException e) { - showErrorDialog( - R.string.account_setup_failed_dlg_certificate_message_fmt, - e.getMessage() == null ? "" : e.getMessage()); - } - AccountSetupCheckSettings.actionCheckSettings(AccountSetupCheckSettings.this, mAccount, - mDirection); - } - - @Override - public void onActivityResult(int reqCode, int resCode, Intent data) { - if (reqCode == ACTIVITY_REQUEST_CODE) { - setResult(resCode); - finish(); - } else { - super.onActivityResult(reqCode, resCode, data); - } - } - - private void onCancel() { - mCanceled = true; - setMessage(R.string.account_setup_check_settings_canceling_msg); - setResult(RESULT_CANCELED); - finish(); - } - - public void onClick(View v) { - if (v.getId() == R.id.cancel) { - onCancel(); - } - } - - private void showErrorDialog(final int msgResId, final Object... args) { - mHandler.post(new Runnable() { - public void run() { - showDialogFragment(R.id.dialog_account_setup_error, getString(msgResId, args)); - } - }); - } - - private void showDialogFragment(int dialogId, String customMessage) { - if (mDestroyed) { - return; - } - mProgressBar.setIndeterminate(false); - - DialogFragment fragment; - if (dialogId == R.id.dialog_account_setup_error) { - fragment = ConfirmationDialogFragment.newInstance(dialogId, - getString(R.string.account_setup_failed_dlg_title), - customMessage, - getString(R.string.account_setup_failed_dlg_edit_details_action), - getString(R.string.account_setup_failed_dlg_continue_action) - ); - } else { - throw new RuntimeException("Called showDialog(int) with unknown dialog id."); - } - - FragmentTransaction ta = getSupportFragmentManager().beginTransaction(); - ta.add(fragment, getDialogTag(dialogId)); - ta.commitAllowingStateLoss(); - - // TODO: commitAllowingStateLoss() is used to prevent https://code.google.com/p/android/issues/detail?id=23761 - // but is a bad... - //fragment.show(ta, getDialogTag(dialogId)); - } - - private String getDialogTag(int dialogId) { - return String.format(Locale.US, "dialog-%d", dialogId); - } - - @Override - public void doPositiveClick(int dialogId) { - if (dialogId == R.id.dialog_account_setup_error) { - finish(); - } - } - - @Override - public void doNegativeClick(int dialogId) { - if (dialogId == R.id.dialog_account_setup_error) { - mCanceled = false; - setResult(RESULT_OK); - finish(); - } - } - - @Override - public void dialogCancelled(int dialogId) { - // nothing to do here... - } - - private String errorMessageForCertificateException(CertificateValidationException e) { - switch (e.getReason()) { - case Expired: return getString(R.string.client_certificate_expired, e.getAlias(), e.getMessage()); - case MissingCapability: return getString(R.string.auth_external_error); - case RetrievalFailure: return getString(R.string.client_certificate_retrieval_failure, e.getAlias()); - case UseMessage: return e.getMessage(); - case Unknown: - default: return ""; - } - } - - /** - * FIXME: Don't use an AsyncTask to perform network operations. - * See also discussion in https://github.com/k9mail/k-9/pull/560 - */ - private class CheckAccountTask extends AsyncTask { - private final Account account; - - private CheckAccountTask(Account account) { - this.account = account; - } - - @Override - protected Void doInBackground(CheckDirection... params) { - final CheckDirection direction = params[0]; - try { - /* - * This task could be interrupted at any point, but network operations can block, - * so relying on InterruptedException is not enough. Instead, check after - * each potentially long-running operation. - */ - if (cancelled()) { - return null; - } - - try { - clearCertificateErrorNotifications(direction); - - checkServerSettings(direction); - - if (cancelled()) { - return null; - } - - setResult(RESULT_OK); - finish(); - } catch (OAuth2NeedUserPromptException ignored) { - //let the user do oauth2 flow procedure through webview - Log.e(className, ignored.getMessage()); - } - - } catch (AuthenticationFailedException afe) { - if (afe.getMessage().equals(AuthenticationFailedException.OAUTH2_ERROR_INVALID_REFRESH_TOKEN)) { - //Do it it in another way - oAuth2CodeGrantFlowManager.invalidateRefreshToken(mAccount.getEmail()); - runOnUiThread(() -> new CheckAccountTask(mAccount).execute(mDirection)); - } else { - Timber.e(afe, "Error while testing settings"); - showErrorDialog( - R.string.account_setup_failed_dlg_auth_message_fmt, - afe.getMessage() == null ? "" : afe.getMessage()); - } - } catch (CertificateValidationException cve) { - handleCertificateValidationException(cve); - } catch (Exception e) { - Timber.e(e, "Error while testing settings"); - String message = e.getMessage() == null ? "" : e.getMessage(); - showErrorDialog(R.string.account_setup_failed_dlg_server_message_fmt, message); - } - return null; - } - - private void clearCertificateErrorNotifications(CheckDirection direction) { - final MessagingController ctrl = MessagingController.getInstance(getApplication()); - boolean incoming = (direction == CheckDirection.INCOMING); - ctrl.clearCertificateErrorNotifications(account, incoming); - } - - private boolean cancelled() { - if (mDestroyed) { - return true; - } - if (mCanceled) { - finish(); - return true; - } - return false; - } - - private void checkServerSettings(CheckDirection direction) throws MessagingException { - switch (direction) { - case INCOMING: { - checkIncoming(); - break; - } - case OUTGOING: { - checkOutgoing(); - break; - } - } - } - - private void checkOutgoing() throws MessagingException { - if (!isWebDavAccount()) { - publishProgress(R.string.account_setup_check_settings_check_outgoing_msg); - } - - messagingController.checkOutgoingServerSettings(account); - } - - private void checkIncoming() throws MessagingException { - if (isWebDavAccount()) { - publishProgress(R.string.account_setup_check_settings_authenticate); - } else { - publishProgress(R.string.account_setup_check_settings_check_incoming_msg); - } - - messagingController.checkIncomingServerSettings(account); - - if (isWebDavAccount()) { - publishProgress(R.string.account_setup_check_settings_fetch); - } - - messagingController.refreshFolderListSynchronous(account); - Long inboxFolderId = account.getInboxFolderId(); - if (inboxFolderId != null) { - messagingController.synchronizeMailbox(account, inboxFolderId, false, null); - } - } - - private boolean isWebDavAccount() { - return account.getIncomingServerSettings().type.equals(Protocols.WEBDAV); - } - - @Override - protected void onProgressUpdate(Integer... values) { - setMessage(values[0]); - } - } - - private final OAuth2PromptRequestHandler promptRequestHandler = new OAuth2PromptRequestHandler() { - - @Override - public void handleRedirectUrl(WebViewClient webViewClient, String url) { - openUrl(webViewClient, url); - } - - @Override - public void onObtainCodeSuccessful() { - if (authDialog != null) { - authDialog.dismiss(); - authDialog = null; - } - } - @Override - public void onObtainAccessTokenSuccessful() { - //restart a settings check - new CheckAccountTask(mAccount).execute(mDirection); - } - - @Override - public void onError(String errorMessage) { - Toast.makeText(AccountSetupCheckSettings.this, errorMessage, Toast.LENGTH_LONG).show(); - finish(); - } - }; - - private void openUrl(WebViewClient webViewClient, String url) { - runOnUiThread(() -> { - CookieManager cookieManager = CookieManager.getInstance(); - //noinspection deprecation - cookieManager.removeAllCookie(); - - authDialog = new Dialog(this); - authDialog.setContentView(R.layout.oauth_webview); - WebView web = authDialog.findViewById(R.id.web_view); - web.getSettings().setSaveFormData(false); - web.getSettings().setJavaScriptEnabled(true); - web.getSettings().setUserAgentString("K9 mail"); - - web.setWebViewClient(webViewClient); - - web.getSettings().setUseWideViewPort(true); - - authDialog.setCancelable(false); - authDialog.show(); - - authDialog.setOnKeyListener((arg0, keyCode, event) -> { - if (keyCode == KeyEvent.KEYCODE_BACK) { - if (web.canGoBack()) { - web.goBack(); - } else { - onCancel(); - } - return true; - } - return false; - }); - - web.loadUrl(url); - }); - } -} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupCheckSettings.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupCheckSettings.kt new file mode 100644 index 0000000000000000000000000000000000000000..c6e88d1796213bf885df60f683ba01dae5d35a60 --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupCheckSettings.kt @@ -0,0 +1,514 @@ +package com.fsck.k9.activity.setup + +import android.app.Activity +import android.app.AlertDialog +import android.content.Intent +import android.os.AsyncTask +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.View +import android.widget.ProgressBar +import android.widget.TextView +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.commit +import com.fsck.k9.Account +import com.fsck.k9.LocalKeyStoreManager +import com.fsck.k9.Preferences +import com.fsck.k9.controller.MessagingController +import com.fsck.k9.fragment.ConfirmationDialogFragment +import com.fsck.k9.fragment.ConfirmationDialogFragment.ConfirmationDialogFragmentListener +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.AuthenticationFailedException +import com.fsck.k9.mail.CertificateValidationException +import com.fsck.k9.mail.MailServerDirection +import com.fsck.k9.mail.filter.Hex +import com.fsck.k9.preferences.Protocols +import com.fsck.k9.ui.R +import com.fsck.k9.ui.base.K9Activity +import com.fsck.k9.ui.observe +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import java.security.cert.CertificateEncodingException +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import java.util.Locale +import java.util.concurrent.Executors +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import timber.log.Timber + +/** + * Checks the given settings to make sure that they can be used to send and receive mail. + * + * XXX NOTE: The manifest for this app has it ignore config changes, because it doesn't correctly deal with restarting + * while its thread is running. + */ +class AccountSetupCheckSettings : K9Activity(), ConfirmationDialogFragmentListener { + private val authViewModel: AuthViewModel by viewModel() + + private val messagingController: MessagingController by inject() + private val preferences: Preferences by inject() + private val localKeyStoreManager: LocalKeyStoreManager by inject() + + private val handler = Handler(Looper.myLooper()!!) + + private lateinit var progressBar: ProgressBar + private lateinit var messageView: TextView + + private lateinit var account: Account + private lateinit var direction: CheckDirection + + @Volatile + private var canceled = false + + @Volatile + private var destroyed = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setLayout(R.layout.account_setup_check_settings) + + authViewModel.init(activityResultRegistry, lifecycle) + + authViewModel.uiState.observe(this) { state -> + when (state) { + AuthFlowState.Idle -> { + return@observe + } + AuthFlowState.Success -> { + startCheckServerSettings() + } + AuthFlowState.Canceled -> { + showErrorDialog(R.string.account_setup_failed_dlg_oauth_flow_canceled) + } + is AuthFlowState.Failed -> { + showErrorDialog(R.string.account_setup_failed_dlg_oauth_flow_failed, state) + } + AuthFlowState.NotSupported -> { + showErrorDialog(R.string.account_setup_failed_dlg_oauth_not_supported) + } + AuthFlowState.BrowserNotFound -> { + showErrorDialog(R.string.account_setup_failed_dlg_browser_not_found) + } + } + + authViewModel.authResultConsumed() + } + + messageView = findViewById(R.id.message) + progressBar = findViewById(R.id.progress) + findViewById(R.id.cancel).setOnClickListener { onCancel() } + + setMessage(R.string.account_setup_check_settings_retr_info_msg) + progressBar.isIndeterminate = true + + val accountUuid = intent.getStringExtra(EXTRA_ACCOUNT) ?: error("Missing account UUID") + account = preferences.getAccount(accountUuid) ?: error("Could not find account") + direction = intent.getSerializableExtra(EXTRA_CHECK_DIRECTION) as CheckDirection? + ?: error("Missing CheckDirection") + + if (savedInstanceState == null) { + if (needsAuthorization()) { + setMessage(R.string.account_setup_check_settings_authenticate) + authViewModel.login(account) + } else { + startCheckServerSettings() + } + } + } + + private fun needsAuthorization(): Boolean { + return ( + account.incomingServerSettings.authenticationType == AuthType.XOAUTH2 || + account.outgoingServerSettings.authenticationType == AuthType.XOAUTH2 + ) && + !authViewModel.isAuthorized(account) + } + + private fun startCheckServerSettings() { + CheckAccountTask(account).executeOnExecutor(Executors.newSingleThreadExecutor(), direction) + } + + private fun handleCertificateValidationException(exception: CertificateValidationException) { + Timber.e(exception, "Error while testing settings") + + val chain = exception.certChain + + // Avoid NullPointerException in acceptKeyDialog() + if (chain != null) { + acceptKeyDialog( + R.string.account_setup_failed_dlg_certificate_message_fmt, + exception + ) + } else { + showErrorDialog( + R.string.account_setup_failed_dlg_server_message_fmt, + errorMessageForCertificateException(exception)!! + ) + } + } + + override fun onDestroy() { + super.onDestroy() + + destroyed = true + canceled = true + } + + private fun setMessage(resId: Int) { + messageView.text = getString(resId) + } + + private fun acceptKeyDialog(msgResId: Int, exception: CertificateValidationException) { + handler.post { + if (destroyed) { + return@post + } + + val errorMessage = exception.cause?.cause?.message ?: exception.cause?.message ?: exception.message + + progressBar.isIndeterminate = false + + val chainInfo = StringBuilder() + val chain = exception.certChain + + // We already know chain != null (tested before calling this method) + for (i in chain.indices) { + // display certificate chain information + // TODO: localize this strings + chainInfo.append("Certificate chain[").append(i).append("]:\n") + chainInfo.append("Subject: ").append(chain[i].subjectDN.toString()).append("\n") + + // display SubjectAltNames too + // (the user may be mislead into mistrusting a certificate + // by a subjectDN not matching the server even though a + // SubjectAltName matches) + try { + val subjectAlternativeNames = chain[i].subjectAlternativeNames + if (subjectAlternativeNames != null) { + // TODO: localize this string + val altNamesText = StringBuilder() + altNamesText.append("Subject has ") + .append(subjectAlternativeNames.size) + .append(" alternative names\n") + + // we need these for matching + val incomingServerHost = account.incomingServerSettings.host!! + val outgoingServerHost = account.outgoingServerSettings.host!! + for (subjectAlternativeName in subjectAlternativeNames) { + val type = subjectAlternativeName[0] as Int + val value: Any? = subjectAlternativeName[1] + val name: String = when (type) { + 0 -> { + Timber.w("SubjectAltName of type OtherName not supported.") + continue + } + 1 -> value as String + 2 -> value as String + 3 -> { + Timber.w("unsupported SubjectAltName of type x400Address") + continue + } + 4 -> { + Timber.w("unsupported SubjectAltName of type directoryName") + continue + } + 5 -> { + Timber.w("unsupported SubjectAltName of type ediPartyName") + continue + } + 6 -> value as String + 7 -> value as String + else -> { + Timber.w("unsupported SubjectAltName of unknown type") + continue + } + } + + // if some of the SubjectAltNames match the store or transport -host, display them + if (name.equals(incomingServerHost, ignoreCase = true) || + name.equals(outgoingServerHost, ignoreCase = true) + ) { + // TODO: localize this string + altNamesText.append("Subject(alt): ").append(name).append(",...\n") + } else if (name.startsWith("*.") && + ( + incomingServerHost.endsWith(name.substring(2)) || + outgoingServerHost.endsWith(name.substring(2)) + ) + ) { + // TODO: localize this string + altNamesText.append("Subject(alt): ").append(name).append(",...\n") + } + } + chainInfo.append(altNamesText) + } + } catch (e: Exception) { + // don't fail just because of subjectAltNames + Timber.w(e, "cannot display SubjectAltNames in dialog") + } + + chainInfo.append("Issuer: ").append(chain[i].issuerDN.toString()).append("\n") + for (algorithm in arrayOf("SHA-1", "SHA-256", "SHA-512")) { + val digest = try { + MessageDigest.getInstance(algorithm) + } catch (e: NoSuchAlgorithmException) { + Timber.e(e, "Error while initializing MessageDigest ($algorithm)") + null + } + + if (digest != null) { + digest.reset() + try { + val hash = Hex.encodeHex(digest.digest(chain[i].encoded)) + chainInfo.append("Fingerprint ($algorithm): ").append("\n").append(hash).append("\n") + } catch (e: CertificateEncodingException) { + Timber.e(e, "Error while encoding certificate") + } + } + } + } + + // TODO: refactor with DialogFragment. + // This is difficult because we need to pass through chain[0] for onClick() + AlertDialog.Builder(this@AccountSetupCheckSettings) + .setTitle(getString(R.string.account_setup_failed_dlg_invalid_certificate_title)) + .setMessage(getString(msgResId, errorMessage) + " " + chainInfo.toString()) + .setCancelable(true) + .setPositiveButton(R.string.account_setup_failed_dlg_invalid_certificate_accept) { _, _ -> + acceptCertificate(chain[0]) + } + .setNegativeButton(R.string.account_setup_failed_dlg_invalid_certificate_reject) { _, _ -> + finish() + } + .show() + } + } + + /** + * Permanently accepts a certificate for the INCOMING or OUTGOING direction by adding it to the local key store. + */ + private fun acceptCertificate(certificate: X509Certificate) { + try { + localKeyStoreManager.addCertificate(account, direction.toMailServerDirection(), certificate) + } catch (e: CertificateException) { + showErrorDialog(R.string.account_setup_failed_dlg_certificate_message_fmt, e.message.orEmpty()) + } + + actionCheckSettings(this@AccountSetupCheckSettings, account, direction) + } + + override fun onActivityResult(reqCode: Int, resCode: Int, data: Intent?) { + if (reqCode == ACTIVITY_REQUEST_CODE) { + setResult(resCode) + finish() + } else { + super.onActivityResult(reqCode, resCode, data) + } + } + + private fun onCancel() { + canceled = true + setMessage(R.string.account_setup_check_settings_canceling_msg) + + setResult(RESULT_CANCELED) + finish() + } + + private fun showErrorDialog(msgResId: Int, vararg args: Any) { + handler.post { + showDialogFragment(R.id.dialog_account_setup_error, getString(msgResId, *args)) + } + } + + private fun showDialogFragment(dialogId: Int, customMessage: String) { + if (destroyed) return + + progressBar.isIndeterminate = false + + val fragment: DialogFragment = if (dialogId == R.id.dialog_account_setup_error) { + ConfirmationDialogFragment.newInstance( + dialogId, + getString(R.string.account_setup_failed_dlg_title), + customMessage, + getString(R.string.account_setup_failed_dlg_edit_details_action), + getString(R.string.account_setup_failed_dlg_continue_action) + ) + } else { + throw RuntimeException("Called showDialog(int) with unknown dialog id.") + } + + // TODO: commitAllowingStateLoss() is used to prevent https://code.google.com/p/android/issues/detail?id=23761 + // but is a bad... + supportFragmentManager.commit(allowStateLoss = true) { + add(fragment, getDialogTag(dialogId)) + } + } + + private fun getDialogTag(dialogId: Int): String { + return String.format(Locale.US, "dialog-%d", dialogId) + } + + override fun doPositiveClick(dialogId: Int) { + if (dialogId == R.id.dialog_account_setup_error) { + finish() + } + } + + override fun doNegativeClick(dialogId: Int) { + if (dialogId == R.id.dialog_account_setup_error) { + canceled = false + setResult(RESULT_OK) + finish() + } + } + + override fun dialogCancelled(dialogId: Int) = Unit + + private fun errorMessageForCertificateException(e: CertificateValidationException): String? { + return when (e.reason) { + CertificateValidationException.Reason.Expired -> { + getString(R.string.client_certificate_expired, e.alias, e.message) + } + CertificateValidationException.Reason.MissingCapability -> { + getString(R.string.auth_external_error) + } + CertificateValidationException.Reason.RetrievalFailure -> { + getString(R.string.client_certificate_retrieval_failure, e.alias) + } + CertificateValidationException.Reason.UseMessage -> { + e.message + } + else -> { + "" + } + } + } + + /** + * FIXME: Don't use an AsyncTask to perform network operations. + * See also discussion in https://github.com/k9mail/k-9/pull/560 + */ + private inner class CheckAccountTask(private val account: Account) : AsyncTask() { + override fun doInBackground(vararg params: CheckDirection) { + val direction = params[0] + try { + /* + * This task could be interrupted at any point, but network operations can block, + * so relying on InterruptedException is not enough. Instead, check after + * each potentially long-running operation. + */ + if (isCanceled()) { + return + } + + clearCertificateErrorNotifications(direction) + + checkServerSettings(direction) + + if (isCanceled()) { + return + } + + setResult(RESULT_OK) + finish() + } catch (e: AuthenticationFailedException) { + Timber.e(e, "Error while testing settings") + showErrorDialog(R.string.account_setup_failed_dlg_auth_message_fmt, e.message.orEmpty()) + } catch (e: CertificateValidationException) { + handleCertificateValidationException(e) + } catch (e: Exception) { + Timber.e(e, "Error while testing settings") + showErrorDialog(R.string.account_setup_failed_dlg_server_message_fmt, e.message.orEmpty()) + } + } + + private fun clearCertificateErrorNotifications(direction: CheckDirection) { + val incoming = direction == CheckDirection.INCOMING + messagingController.clearCertificateErrorNotifications(account, incoming) + } + + private fun isCanceled(): Boolean { + if (destroyed) return true + + if (canceled) { + finish() + return true + } + + return false + } + + private fun checkServerSettings(direction: CheckDirection) { + when (direction) { + CheckDirection.INCOMING -> checkIncoming() + CheckDirection.OUTGOING -> checkOutgoing() + } + } + + private fun checkOutgoing() { + if (!isWebDavAccount) { + publishProgress(R.string.account_setup_check_settings_check_outgoing_msg) + } + + messagingController.checkOutgoingServerSettings(account) + } + + private fun checkIncoming() { + if (isWebDavAccount) { + publishProgress(R.string.account_setup_check_settings_authenticate) + } else { + publishProgress(R.string.account_setup_check_settings_check_incoming_msg) + } + + messagingController.checkIncomingServerSettings(account) + + if (isWebDavAccount) { + publishProgress(R.string.account_setup_check_settings_fetch) + } + + messagingController.refreshFolderListSynchronous(account) + + val inboxFolderId = account.inboxFolderId + if (inboxFolderId != null) { + messagingController.synchronizeMailbox(account, inboxFolderId, false, null) + } + } + + private val isWebDavAccount: Boolean + get() = account.incomingServerSettings.type == Protocols.WEBDAV + + override fun onProgressUpdate(vararg values: Int?) { + setMessage(values[0]!!) + } + } + + enum class CheckDirection { + INCOMING, OUTGOING; + + fun toMailServerDirection(): MailServerDirection { + return when (this) { + INCOMING -> MailServerDirection.INCOMING + OUTGOING -> MailServerDirection.OUTGOING + } + } + } + + companion object { + const val ACTIVITY_REQUEST_CODE = 1 + + private const val EXTRA_ACCOUNT = "account" + private const val EXTRA_CHECK_DIRECTION = "checkDirection" + + @JvmStatic + fun actionCheckSettings(context: Activity, account: Account, direction: CheckDirection) { + val intent = Intent(context, AccountSetupCheckSettings::class.java).apply { + putExtra(EXTRA_ACCOUNT, account.uuid) + putExtra(EXTRA_CHECK_DIRECTION, direction) + } + + context.startActivityForResult(intent, ACTIVITY_REQUEST_CODE) + } + } +} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupComposition.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupComposition.java index 719952ab2aa985a02b8012ee9b339ef4e5ef386b..10315606fd087ca4e6211251fa105bb2c34f3b79 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupComposition.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AccountSetupComposition.java @@ -3,12 +3,15 @@ package com.fsck.k9.activity.setup; import android.app.Activity; import android.content.Intent; import android.os.Bundle; +import android.view.MenuItem; import android.view.View; import android.widget.CompoundButton; import android.widget.CheckBox; import android.widget.EditText; import android.widget.LinearLayout; import android.widget.RadioButton; + +import androidx.annotation.NonNull; import com.fsck.k9.Account; import com.fsck.k9.Preferences; import com.fsck.k9.ui.R; @@ -48,6 +51,10 @@ public class AccountSetupComposition extends K9Activity { setLayout(R.layout.account_setup_composition); setTitle(R.string.account_settings_composition_title); + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + /* * If we're being reloaded we override the original account with the one * we saved @@ -101,6 +108,16 @@ public class AccountSetupComposition extends K9Activity { } } + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + if (item.getItemId() == android.R.id.home) { + onBackPressed(); + return true; + } + + return super.onOptionsItemSelected(item); + } + private void saveSettings() { mAccount.setEmail(mAccountEmail.getText().toString()); mAccount.setAlwaysBcc(mAccountAlwaysBcc.getText().toString()); 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 8009c42a48433c1fec1f0ef46851c3d22ce4b164..b8f971010e03ce364b519c83822356f54b13ecd7 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 @@ -2,6 +2,9 @@ package com.fsck.k9.activity.setup; +import java.util.Locale; +import java.util.Map; + import android.app.Activity; import android.content.Context; import android.content.Intent; @@ -9,6 +12,7 @@ import android.os.Bundle; import android.text.Editable; import android.text.TextWatcher; import android.text.method.DigitsKeyListener; +import android.view.MenuItem; import android.view.View; import android.view.View.OnClickListener; import android.view.ViewGroup; @@ -27,29 +31,22 @@ import com.fsck.k9.DI; import com.fsck.k9.LocalKeyStoreManager; import com.fsck.k9.Preferences; import com.fsck.k9.account.AccountCreator; -import com.fsck.k9.helper.EmailHelper; -import com.fsck.k9.setup.ServerNameSuggester; -import com.fsck.k9.ui.base.K9Activity; import com.fsck.k9.activity.setup.AccountSetupCheckSettings.CheckDirection; -import com.fsck.k9.controller.MessagingController; +import com.fsck.k9.helper.EmailHelper; import com.fsck.k9.helper.Utility; -import com.fsck.k9.job.K9JobManager; import com.fsck.k9.mail.AuthType; import com.fsck.k9.mail.ConnectionSecurity; import com.fsck.k9.mail.MailServerDirection; -import com.fsck.k9.mail.NetworkType; import com.fsck.k9.mail.ServerSettings; 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.setup.ServerNameSuggester; import com.fsck.k9.ui.R; +import com.fsck.k9.ui.base.K9Activity; 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; import com.google.android.material.textfield.TextInputLayout; import timber.log.Timber; @@ -63,8 +60,6 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener private static final String STATE_SECURITY_TYPE_POSITION = "stateSecurityTypePosition"; private static final String STATE_AUTH_TYPE_POSITION = "authTypePosition"; - private final MessagingController messagingController = DI.get(MessagingController.class); - private final K9JobManager jobManager = DI.get(K9JobManager.class); private final AccountCreator accountCreator = DI.get(AccountCreator.class); private final ServerNameSuggester serverNameSuggester = DI.get(ServerNameSuggester.class); @@ -89,9 +84,7 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener private Button mNextButton; private Account mAccount; private boolean mMakeDefault; - private CheckBox mCompressionMobile; - private CheckBox mCompressionWifi; - private CheckBox mCompressionOther; + private CheckBox useCompressionCheckBox; private CheckBox mSubscribedFoldersOnly; private AuthTypeAdapter mAuthTypeAdapter; private ConnectionSecurity[] mConnectionSecurityChoices = ConnectionSecurity.values(); @@ -138,9 +131,7 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener mWebdavAuthPathView = findViewById(R.id.webdav_auth_path); mWebdavMailboxPathView = findViewById(R.id.webdav_mailbox_path); mNextButton = findViewById(R.id.next); - mCompressionMobile = findViewById(R.id.compression_mobile); - mCompressionWifi = findViewById(R.id.compression_wifi); - mCompressionOther = findViewById(R.id.compression_other); + useCompressionCheckBox = findViewById(R.id.use_compression); mSubscribedFoldersOnly = findViewById(R.id.subscribed_folders_only); mAllowClientCertificateView = findViewById(R.id.account_allow_client_certificate); @@ -160,9 +151,6 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener } }); - mAuthTypeAdapter = AuthTypeAdapter.get(this); - mAuthTypeView.setAdapter(mAuthTypeAdapter); - /* * Only allow digits in the port field. */ @@ -181,6 +169,10 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener mAccount = Preferences.getPreferences(this).getAccount(accountUuid); } + boolean oAuthSupported = mAccount.getIncomingServerSettings().type.equals(Protocols.IMAP); + mAuthTypeAdapter = AuthTypeAdapter.get(this, oAuthSupported); + mAuthTypeView.setAdapter(mAuthTypeAdapter); + boolean editSettings = Intent.ACTION_EDIT.equals(getIntent().getAction()); if (editSettings) { TextInputLayoutHelper.configureAuthenticatedPasswordToggle( @@ -190,6 +182,10 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener getString(R.string.account_setup_basics_show_password_biometrics_subtitle), getString(R.string.account_setup_basics_show_password_need_lock) ); + + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } } try { @@ -222,8 +218,7 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener findViewById(R.id.webdav_mailbox_alias_section).setVisibility(View.GONE); findViewById(R.id.webdav_owa_path_section).setVisibility(View.GONE); findViewById(R.id.webdav_auth_path_section).setVisibility(View.GONE); - findViewById(R.id.compression_section).setVisibility(View.GONE); - findViewById(R.id.compression_label).setVisibility(View.GONE); + useCompressionCheckBox.setVisibility(View.GONE); mSubscribedFoldersOnly.setVisibility(View.GONE); } else if (settings.type.equals(Protocols.IMAP)) { serverLayoutView.setHint(getString(R.string.account_setup_incoming_imap_server_label)); @@ -254,8 +249,7 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener findViewById(R.id.imap_path_prefix_section).setVisibility(View.GONE); findViewById(R.id.account_auth_type_label).setVisibility(View.GONE); findViewById(R.id.account_auth_type).setVisibility(View.GONE); - findViewById(R.id.compression_section).setVisibility(View.GONE); - findViewById(R.id.compression_label).setVisibility(View.GONE); + useCompressionCheckBox.setVisibility(View.GONE); mSubscribedFoldersOnly.setVisibility(View.GONE); String path = WebDavStoreSettings.getPath(settings); @@ -287,7 +281,8 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener // Select currently configured security type if (savedInstanceState == null) { - mCurrentSecurityTypeViewPosition = securityTypesAdapter.getConnectionSecurityPosition(settings.connectionSecurity); + mCurrentSecurityTypeViewPosition = + securityTypesAdapter.getConnectionSecurityPosition(settings.connectionSecurity); } else { /* @@ -305,9 +300,7 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener updateAuthPlainTextFromSecurityType(settings.connectionSecurity); updateViewFromSecurity(); - mCompressionMobile.setChecked(mAccount.useCompression(NetworkType.MOBILE)); - mCompressionWifi.setChecked(mAccount.useCompression(NetworkType.WIFI)); - mCompressionOther.setChecked(mAccount.useCompression(NetworkType.OTHER)); + useCompressionCheckBox.setChecked(mAccount.useCompression()); if (settings.host != null) { mServerView.setText(settings.host); @@ -327,11 +320,9 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener } /** - * Called at the end of either {@code onCreate()} or - * {@code onRestoreInstanceState()}, after the views have been initialized, - * so that the listeners are not triggered during the view initialization. - * This avoids needless calls to {@code validateFields()} which is called - * immediately after this is called. + * Called at the end of either {@code onCreate()} or {@code onRestoreInstanceState()}, after the views have been + * initialized, so that the listeners are not triggered during the view initialization. This avoids needless calls + * to {@code validateFields()} which is called immediately after this is called. */ private void initializeViewListeners() { @@ -375,8 +366,8 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener validateFields(); AuthType selection = getSelectedAuthType(); - // Have the user select the client certificate if not already selected - if ((AuthType.EXTERNAL == selection) && (mClientCertificateSpinner.getAlias() == null)) { + // Have the user select the client certificate if not already selected + if ((AuthType.EXTERNAL == selection) && (mClientCertificateSpinner.getAlias() == null)) { // This may again invoke validateFields() mClientCertificateSpinner.chooseCertificate(); } else { @@ -423,11 +414,9 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener switch (getSelectedAuthType()) { case EXTERNAL: case XOAUTH2: - // hide password fields, show client certificate fields mPasswordLayoutView.setVisibility(View.GONE); break; default: - // show password fields, hide client certificate fields mPasswordLayoutView.setVisibility(View.VISIBLE); break; } @@ -439,10 +428,11 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener */ private void updateViewFromSecurity() { ConnectionSecurity security = getSelectedSecurity(); - boolean isUsingTLS = ((ConnectionSecurity.SSL_TLS_REQUIRED == security) || (ConnectionSecurity.STARTTLS_REQUIRED == security)); - boolean isUsingXoauth2 = getSelectedAuthType() == AuthType.XOAUTH2; + boolean isUsingTLS = ((ConnectionSecurity.SSL_TLS_REQUIRED == security) || + (ConnectionSecurity.STARTTLS_REQUIRED == security)); + boolean isUsingOAuth = getSelectedAuthType() == AuthType.XOAUTH2; - if (isUsingTLS && !isUsingXoauth2) { + if (isUsingTLS && !isUsingOAuth) { mAllowClientCertificateView.setVisibility(View.VISIBLE); } else { mAllowClientCertificateView.setVisibility(View.GONE); @@ -450,9 +440,8 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener } /** - * This is invoked only when the user makes changes to a widget, not when - * widgets are changed programmatically. (The logic is simpler when you know - * that this is the last thing called after an input change.) + * This is invoked only when the user makes changes to a widget, not when widgets are changed programmatically. + * (The logic is simpler when you know that this is the last thing called after an input change.) */ private void validateFields() { AuthType authType = getSelectedAuthType(); @@ -513,12 +502,13 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener && hasConnectionSecurity && hasValidCertificateAlias; - boolean hasValidXOAuth2Settings = hasValidUserName - && AuthType.XOAUTH2 == authType; + boolean hasValidOAuthSettings = hasValidUserName + && hasConnectionSecurity + && authType == AuthType.XOAUTH2; mNextButton.setEnabled(Utility.domainFieldValid(mServerView) && Utility.requiredFieldValid(mPortView) - && (hasValidPasswordSettings || hasValidExternalAuthSettings || hasValidXOAuth2Settings)); + && (hasValidPasswordSettings || hasValidExternalAuthSettings || hasValidOAuthSettings)); Utility.setCompoundDrawablesAlpha(mNextButton, mNextButton.isEnabled() ? 255 : 128); } @@ -560,7 +550,7 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener String clientCertificateAlias = null; AuthType authType = getSelectedAuthType(); if ((ConnectionSecurity.SSL_TLS_REQUIRED == getSelectedSecurity()) || - (ConnectionSecurity.STARTTLS_REQUIRED == getSelectedSecurity()) ) { + (ConnectionSecurity.STARTTLS_REQUIRED == getSelectedSecurity())) { clientCertificateAlias = mClientCertificateSpinner.getAlias(); } if (AuthType.EXTERNAL != authType) { @@ -579,6 +569,16 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener } } + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + if (item.getItemId() == android.R.id.home) { + onBackPressed(); + return true; + } + + return super.onOptionsItemSelected(item); + } + protected void onNext() { try { ConnectionSecurity connectionSecurity = getSelectedSecurity(); @@ -590,7 +590,7 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener AuthType authType = getSelectedAuthType(); if ((ConnectionSecurity.SSL_TLS_REQUIRED == connectionSecurity) || - (ConnectionSecurity.STARTTLS_REQUIRED == connectionSecurity) ) { + (ConnectionSecurity.STARTTLS_REQUIRED == connectionSecurity)) { clientCertificateAlias = mClientCertificateSpinner.getAlias(); } if (authType != AuthType.EXTERNAL) { @@ -617,9 +617,7 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener mAccount.setIncomingServerSettings(settings); - mAccount.setCompression(NetworkType.MOBILE, mCompressionMobile.isChecked()); - mAccount.setCompression(NetworkType.WIFI, mCompressionWifi.isChecked()); - mAccount.setCompression(NetworkType.OTHER, mCompressionOther.isChecked()); + mAccount.setUseCompression(useCompressionCheckBox.isChecked()); mAccount.setSubscribedFoldersOnly(mSubscribedFoldersOnly.isChecked()); AccountSetupCheckSettings.actionCheckSettings(this, mAccount, CheckDirection.INCOMING); 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 3961748ba6330d1e01b22362fc63f7512d6a01f7..61bf5212392a2610adda24e39405e0a214ef5fd0 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 @@ -10,6 +10,7 @@ import android.os.Bundle; import android.text.Editable; import android.text.TextWatcher; import android.text.method.DigitsKeyListener; +import android.view.MenuItem; import android.view.View; import android.view.View.OnClickListener; import android.view.ViewGroup; @@ -44,8 +45,9 @@ import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputLayout; import timber.log.Timber; + public class AccountSetupOutgoing extends K9Activity implements OnClickListener, - OnCheckedChangeListener { + OnCheckedChangeListener { private static final String EXTRA_ACCOUNT = "account"; private static final String STATE_SECURITY_TYPE_POSITION = "stateSecurityTypePosition"; @@ -127,7 +129,7 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, mSecurityTypeView.setAdapter(ConnectionSecurityAdapter.get(this)); - mAuthTypeAdapter = AuthTypeAdapter.get(this); + mAuthTypeAdapter = AuthTypeAdapter.get(this, true); mAuthTypeView.setAdapter(mAuthTypeAdapter); /* @@ -157,13 +159,15 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, getString(R.string.account_setup_basics_show_password_biometrics_subtitle), getString(R.string.account_setup_basics_show_password_need_lock) ); + + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } } try { ServerSettings settings = mAccount.getOutgoingServerSettings(); - updateAuthPlainTextFromSecurityType(settings.connectionSecurity); - updateViewFromSecurity(settings.connectionSecurity); if (savedInstanceState == null) { // The first item is selected if settings.authenticationType is null or is not in mAuthTypeAdapter mCurrentAuthTypeViewPosition = mAuthTypeAdapter.getAuthPosition(settings.authenticationType); @@ -190,6 +194,9 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, } mSecurityTypeView.setSelection(mCurrentSecurityTypeViewPosition, false); + updateAuthPlainTextFromSecurityType(getSelectedSecurity()); + updateViewFromSecurity(); + if (!settings.username.isEmpty()) { mUsernameView.setText(settings.username); mRequireLoginView.setChecked(true); @@ -224,11 +231,9 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, } /** - * Called at the end of either {@code onCreate()} or - * {@code onRestoreInstanceState()}, after the views have been initialized, - * so that the listeners are not triggered during the view initialization. - * This avoids needless calls to {@code validateFields()} which is called - * immediately after this is called. + * Called at the end of either {@code onCreate()} or {@code onRestoreInstanceState()}, after the views have been + * initialized, so that the listeners are not triggered during the view initialization. This avoids needless calls + * to {@code validateFields()} which is called immediately after this is called. */ private void initializeViewListeners() { @@ -251,7 +256,7 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, */ if (mCurrentSecurityTypeViewPosition != position) { updatePortFromSecurityType(); - updateViewFromSecurity(getSelectedSecurity()); + updateViewFromSecurity(); boolean isInsecure = (ConnectionSecurity.NONE == getSelectedSecurity()); boolean isAuthExternal = (AuthType.EXTERNAL == getSelectedAuthType()); boolean loginNotRequired = !mRequireLoginView.isChecked(); @@ -292,6 +297,7 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, } updateViewFromAuthType(); + updateViewFromSecurity(); validateFields(); AuthType selection = getSelectedAuthType(); @@ -348,6 +354,16 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, validateFields(); } + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + if (item.getItemId() == android.R.id.home) { + onBackPressed(); + return true; + } + + return super.onOptionsItemSelected(item); + } + /** * Shows/hides password field */ @@ -355,22 +371,24 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, switch (getSelectedAuthType()) { case EXTERNAL: case XOAUTH2: - // hide password fields mPasswordLayoutView.setVisibility(View.GONE); + break; default: - // show password fields mPasswordLayoutView.setVisibility(View.VISIBLE); + break; } } /** * Shows/hides client certificate spinner */ - private void updateViewFromSecurity(ConnectionSecurity security) { - boolean isUsingTLS = ((ConnectionSecurity.SSL_TLS_REQUIRED == security) || (ConnectionSecurity.STARTTLS_REQUIRED == security)); - boolean isUsingXoauth2 = getSelectedAuthType() == AuthType.XOAUTH2; + private void updateViewFromSecurity() { + ConnectionSecurity security = getSelectedSecurity(); + boolean isUsingTLS = ((ConnectionSecurity.SSL_TLS_REQUIRED == security) || + (ConnectionSecurity.STARTTLS_REQUIRED == security)); + boolean isUsingOAuth = getSelectedAuthType() == AuthType.XOAUTH2; - if (isUsingTLS && !isUsingXoauth2) { + if (isUsingTLS && !isUsingOAuth) { mAllowClientCertificateView.setVisibility(View.VISIBLE); } else { mAllowClientCertificateView.setVisibility(View.GONE); @@ -379,9 +397,8 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, /** - * This is invoked only when the user makes changes to a widget, not when - * widgets are changed programmatically. (The logic is simpler when you know - * that this is the last thing called after an input change.) + * This is invoked only when the user makes changes to a widget, not when widgets are changed programmatically. + * (The logic is simpler when you know that this is the last thing called after an input change.) */ private void validateFields() { AuthType authType = getSelectedAuthType(); @@ -406,7 +423,7 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, mAuthTypeView.setSelection(mCurrentAuthTypeViewPosition, false); mAuthTypeView.setOnItemSelectedListener(onItemSelectedListener); updateViewFromAuthType(); - updateViewFromSecurity(getSelectedSecurity()); + updateViewFromSecurity(); onItemSelectedListener = mSecurityTypeView.getOnItemSelectedListener(); mSecurityTypeView.setOnItemSelectedListener(null); @@ -441,14 +458,15 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener, && hasConnectionSecurity && hasValidCertificateAlias; - boolean hasValidXOAuth2Settings = hasValidUserName - && AuthType.XOAUTH2 == authType; + boolean hasValidOAuthSettings = hasValidUserName + && hasConnectionSecurity + && authType == AuthType.XOAUTH2; mNextButton .setEnabled(Utility.domainFieldValid(mServerView) && Utility.requiredFieldValid(mPortView) && (!mRequireLoginView.isChecked() - || hasValidPasswordSettings || hasValidExternalAuthSettings || hasValidXOAuth2Settings)); + || hasValidPasswordSettings || hasValidExternalAuthSettings || hasValidOAuthSettings)); Utility.setCompoundDrawablesAlpha(mNextButton, mNextButton.isEnabled() ? 255 : 128); } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AuthTypeAdapter.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AuthTypeAdapter.java index c094097552b7a352ad700033402aa880f6925dbb..2df6c5e683d6a8e5b3a172fe194671ef6c7efb3c 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AuthTypeAdapter.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AuthTypeAdapter.java @@ -11,8 +11,14 @@ class AuthTypeAdapter extends ArrayAdapter { super(context, resource, holders); } - public static AuthTypeAdapter get(Context context) { - AuthType[] authTypes = new AuthType[]{AuthType.PLAIN, AuthType.CRAM_MD5, AuthType.EXTERNAL, AuthType.XOAUTH2}; + public static AuthTypeAdapter get(Context context, boolean oAuthSupported) { + AuthType[] authTypes; + if (oAuthSupported) { + authTypes = new AuthType[] { AuthType.PLAIN, AuthType.CRAM_MD5, AuthType.EXTERNAL, AuthType.XOAUTH2 }; + } else { + authTypes = new AuthType[] { AuthType.PLAIN, AuthType.CRAM_MD5, AuthType.EXTERNAL }; + } + AuthTypeHolder[] holders = new AuthTypeHolder[authTypes.length]; for (int i = 0; i < authTypes.length; i++) { holders[i] = new AuthTypeHolder(authTypes[i], context.getResources()); diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AuthTypeHolder.java b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AuthTypeHolder.java index eda9d1d122392f35ce28f70ba2ad2a1d55b3d9bc..a12739d55a3492b26fa7b5b0cee3297c5d8b2912 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AuthTypeHolder.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AuthTypeHolder.java @@ -42,7 +42,7 @@ class AuthTypeHolder { case EXTERNAL: return R.string.account_setup_auth_type_tls_client_certificate; case XOAUTH2: - return R.string.account_setup_auth_type_xoauth2; + return R.string.account_setup_auth_type_oauth2; case AUTOMATIC: case LOGIN: default: diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AuthViewModel.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AuthViewModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..fb66d03c7b22c1a8343b642aeaefd5c4ad0de6c3 --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/AuthViewModel.kt @@ -0,0 +1,261 @@ +package com.fsck.k9.activity.setup + +import android.app.Activity +import android.app.Application +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.contract.ActivityResultContract +import androidx.core.net.toUri +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.viewModelScope +import com.fsck.k9.Account +import com.fsck.k9.oauth.OAuthConfiguration +import com.fsck.k9.oauth.OAuthConfigurationProvider +import com.fsck.k9.preferences.AccountManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationResponse +import net.openid.appauth.AuthorizationService +import net.openid.appauth.AuthorizationServiceConfiguration +import net.openid.appauth.ResponseTypeValues +import timber.log.Timber + +private const val KEY_AUTHORIZATION = "app.k9mail_auth" + +class AuthViewModel( + application: Application, + private val accountManager: AccountManager, + private val oAuthConfigurationProvider: OAuthConfigurationProvider +) : AndroidViewModel(application) { + private var authService: AuthorizationService? = null + private val authState = AuthState() + + private var account: Account? = null + + private lateinit var resultObserver: AppAuthResultObserver + + private val _uiState = MutableStateFlow(AuthFlowState.Idle) + val uiState: StateFlow = _uiState.asStateFlow() + + @Synchronized + private fun getAuthService(): AuthorizationService { + return authService ?: AuthorizationService(getApplication()).also { authService = it } + } + + fun init(activityResultRegistry: ActivityResultRegistry, lifecycle: Lifecycle) { + resultObserver = AppAuthResultObserver(activityResultRegistry) + lifecycle.addObserver(resultObserver) + } + + fun authResultConsumed() { + _uiState.update { AuthFlowState.Idle } + } + + fun isAuthorized(account: Account): Boolean { + val authState = getOrCreateAuthState(account) + return authState.isAuthorized + } + + fun isUsingGoogle(account: Account): Boolean { + return oAuthConfigurationProvider.isGoogle(account.incomingServerSettings.host!!) + } + + private fun getOrCreateAuthState(account: Account): AuthState { + return try { + account.oAuthState?.let { AuthState.jsonDeserialize(it) } ?: AuthState() + } catch (e: Exception) { + Timber.e(e, "Error deserializing AuthState") + AuthState() + } + } + + fun login(account: Account) { + this.account = account + + viewModelScope.launch { + val config = findOAuthConfiguration(account) + if (config == null) { + _uiState.update { AuthFlowState.NotSupported } + return@launch + } + + try { + startLogin(account, config) + } catch (e: ActivityNotFoundException) { + _uiState.update { AuthFlowState.BrowserNotFound } + } + } + } + + private suspend fun startLogin(account: Account, config: OAuthConfiguration) { + val authRequestIntent = withContext(Dispatchers.IO) { + createAuthorizationRequestIntent(account.email, config) + } + + resultObserver.login(authRequestIntent) + } + + private fun createAuthorizationRequestIntent(email: String, config: OAuthConfiguration): Intent { + val serviceConfig = AuthorizationServiceConfiguration( + config.authorizationEndpoint.toUri(), + config.tokenEndpoint.toUri() + ) + + val authRequestBuilder = AuthorizationRequest.Builder( + serviceConfig, + config.clientId, + ResponseTypeValues.CODE, + config.redirectUri.toUri() + ) + + val scopeString = config.scopes.joinToString(separator = " ") + val authRequest = authRequestBuilder + .setScope(scopeString) + .setLoginHint(email) + .build() + + val authService = getAuthService() + + return authService.getAuthorizationRequestIntent(authRequest) + } + + private fun findOAuthConfiguration(account: Account): OAuthConfiguration? { + return oAuthConfigurationProvider.getConfiguration(account.incomingServerSettings.host!!) + } + + private fun onLoginResult(authorizationResult: AuthorizationResult?) { + if (authorizationResult == null) { + _uiState.update { AuthFlowState.Canceled } + return + } + + authorizationResult.response?.let { response -> + authState.update(authorizationResult.response, authorizationResult.exception) + exchangeToken(response) + } + + authorizationResult.exception?.let { authorizationException -> + _uiState.update { + AuthFlowState.Failed( + errorCode = authorizationException.error, + errorMessage = authorizationException.errorDescription + ) + } + } + } + + private fun exchangeToken(response: AuthorizationResponse) { + viewModelScope.launch(Dispatchers.IO) { + val authService = getAuthService() + + val tokenRequest = response.createTokenExchangeRequest() + authService.performTokenRequest(tokenRequest) { tokenResponse, authorizationException -> + authState.update(tokenResponse, authorizationException) + + val account = account!! + account.oAuthState = authState.jsonSerializeString() + + viewModelScope.launch(Dispatchers.IO) { + accountManager.saveAccount(account) + } + + if (authorizationException != null) { + _uiState.update { + AuthFlowState.Failed( + errorCode = authorizationException.error, + errorMessage = authorizationException.errorDescription + ) + } + } else { + _uiState.update { AuthFlowState.Success } + } + } + } + } + + @Synchronized + override fun onCleared() { + authService?.dispose() + authService = null + } + + inner class AppAuthResultObserver(private val registry: ActivityResultRegistry) : DefaultLifecycleObserver { + private var authorizationLauncher: ActivityResultLauncher? = null + private var authRequestIntent: Intent? = null + + override fun onCreate(owner: LifecycleOwner) { + authorizationLauncher = registry.register(KEY_AUTHORIZATION, AuthorizationContract(), ::onLoginResult) + authRequestIntent?.let { intent -> + authRequestIntent = null + login(intent) + } + } + + override fun onDestroy(owner: LifecycleOwner) { + authorizationLauncher = null + } + + fun login(authRequestIntent: Intent) { + val launcher = authorizationLauncher + if (launcher != null) { + launcher.launch(authRequestIntent) + } else { + this.authRequestIntent = authRequestIntent + } + } + } +} + +private class AuthorizationContract : ActivityResultContract() { + override fun createIntent(context: Context, input: Intent): Intent { + return input + } + + override fun parseResult(resultCode: Int, intent: Intent?): AuthorizationResult? { + return if (resultCode == Activity.RESULT_OK && intent != null) { + AuthorizationResult( + response = AuthorizationResponse.fromIntent(intent), + exception = AuthorizationException.fromIntent(intent) + ) + } else { + null + } + } +} + +private data class AuthorizationResult( + val response: AuthorizationResponse?, + val exception: AuthorizationException? +) + +sealed interface AuthFlowState { + object Idle : AuthFlowState + + object Success : AuthFlowState + + object NotSupported : AuthFlowState + + object BrowserNotFound : AuthFlowState + + object Canceled : AuthFlowState + + data class Failed(val errorCode: String?, val errorMessage: String?) : AuthFlowState { + override fun toString(): String { + return listOfNotNull(errorCode, errorMessage).joinToString(separator = " - ") + } + } +} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/OAuthFlowActivity.kt b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/OAuthFlowActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..b47729f2e2ce5cfb73ed191df7968d92dfacca81 --- /dev/null +++ b/app/ui/legacy/src/main/java/com/fsck/k9/activity/setup/OAuthFlowActivity.kt @@ -0,0 +1,114 @@ +package com.fsck.k9.activity.setup + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.Button +import android.widget.ProgressBar +import android.widget.TextView +import androidx.core.view.isVisible +import com.fsck.k9.Account +import com.fsck.k9.preferences.AccountManager +import com.fsck.k9.ui.R +import com.fsck.k9.ui.base.K9Activity +import com.fsck.k9.ui.observe +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel + +class OAuthFlowActivity : K9Activity() { + private val authViewModel: AuthViewModel by viewModel() + private val accountManager: AccountManager by inject() + + private lateinit var errorText: TextView + private lateinit var signInButton: Button + private lateinit var signInProgress: ProgressBar + + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setLayout(R.layout.account_setup_oauth) + setTitle(R.string.account_setup_basics_title) + + val accountUUid = intent.getStringExtra(EXTRA_ACCOUNT_UUID) ?: error("Missing account UUID") + val account = accountManager.getAccount(accountUUid) ?: error("Account not found") + + errorText = findViewById(R.id.error_text) + signInProgress = findViewById(R.id.sign_in_progress) + signInButton = if (authViewModel.isUsingGoogle(account)) { + findViewById(R.id.google_sign_in_button) + } else { + findViewById(R.id.oauth_sign_in_button) + } + + signInButton.isVisible = true + signInButton.setOnClickListener { startOAuthFlow(account) } + + savedInstanceState?.let { + val signInRunning = it.getBoolean(STATE_PROGRESS) + signInButton.isVisible = !signInRunning + signInProgress.isVisible = signInRunning + } + + authViewModel.init(activityResultRegistry, lifecycle) + + authViewModel.uiState.observe(this) { state -> + handleUiUpdates(state) + } + } + + private fun handleUiUpdates(state: AuthFlowState) { + when (state) { + AuthFlowState.Idle -> { + return + } + AuthFlowState.Success -> { + setResult(RESULT_OK) + finish() + } + AuthFlowState.Canceled -> { + displayErrorText(R.string.account_setup_failed_dlg_oauth_flow_canceled) + } + is AuthFlowState.Failed -> { + displayErrorText(R.string.account_setup_failed_dlg_oauth_flow_failed, state) + } + AuthFlowState.NotSupported -> { + displayErrorText(R.string.account_setup_failed_dlg_oauth_not_supported) + } + AuthFlowState.BrowserNotFound -> { + displayErrorText(R.string.account_setup_failed_dlg_browser_not_found) + } + } + + authViewModel.authResultConsumed() + } + + private fun displayErrorText(errorTextResId: Int, vararg args: Any?) { + signInProgress.isVisible = false + signInButton.isVisible = true + errorText.text = getString(errorTextResId, *args) + } + + private fun startOAuthFlow(account: Account) { + signInButton.isVisible = false + signInProgress.isVisible = true + errorText.text = "" + + authViewModel.login(account) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putBoolean(STATE_PROGRESS, signInProgress.isVisible) + } + + companion object { + private const val EXTRA_ACCOUNT_UUID = "accountUuid" + + private const val STATE_PROGRESS = "signInProgress" + + fun buildLaunchIntent(context: Context, accountUuid: String): Intent { + return Intent(context, OAuthFlowActivity::class.java).apply { + putExtra(EXTRA_ACCOUNT_UUID, accountUuid) + } + } + } +} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListAdapter.kt b/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListAdapter.kt index 4b462b7ce429b9cb178e1a7e000279181bacdfc5..197f1dea1af1ba8f04d993047a3adc9d2164d7a8 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListAdapter.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/fragment/MessageListAdapter.kt @@ -90,6 +90,12 @@ class MessageListAdapter internal constructor( listItemListener.onToggleMessageSelection(messageListItem) } + private fun recipientSigil(toMe: Boolean, ccMe: Boolean) = when { + toMe -> res.getString(R.string.messagelist_sent_to_me_sigil) + " " + ccMe -> res.getString(R.string.messagelist_sent_cc_me_sigil) + " " + else -> "" + } + override fun hasStableIds(): Boolean = true override fun getCount(): Int = messages.size diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/dialog/ApgDeprecationWarningDialog.java b/app/ui/legacy/src/main/java/com/fsck/k9/ui/dialog/ApgDeprecationWarningDialog.java deleted file mode 100644 index 093cff9207aebec58c019542fb249e90c1eb6a12..0000000000000000000000000000000000000000 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/dialog/ApgDeprecationWarningDialog.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.fsck.k9.ui.dialog; - - -import android.annotation.SuppressLint; -import android.app.AlertDialog; -import android.app.Dialog; -import android.content.Context; -import android.content.DialogInterface; -import android.text.method.LinkMovementMethod; -import android.view.LayoutInflater; -import android.view.View; -import android.widget.TextView; - -import com.fsck.k9.ui.R; - - -public class ApgDeprecationWarningDialog extends AlertDialog { - public ApgDeprecationWarningDialog(Context context) { - super(context); - - LayoutInflater inflater = LayoutInflater.from(context); - - @SuppressLint("InflateParams") - View contentView = inflater.inflate(R.layout.dialog_apg_deprecated, null); - - TextView textViewLearnMore = contentView.findViewById(R.id.apg_learn_more); - makeTextViewLinksClickable(textViewLearnMore); - - setIcon(R.drawable.ic_apg_small); - setTitle(R.string.apg_deprecated_title); - setView(contentView); - setButton(Dialog.BUTTON_POSITIVE, context.getString(R.string.apg_deprecated_ok), new OnClickListener() { - @Override - public void onClick(DialogInterface dialogInterface, int i) { - cancel(); - } - }); - } - - private void makeTextViewLinksClickable(TextView textView) { - textView.setMovementMethod(LinkMovementMethod.getInstance()); - } -} diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/AttachmentController.java b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/AttachmentController.java index 1248e19e462cbfb4b4b9b9d351bfb92cbbc13a93..fab62cbab326b485e5fdd684f444ace7b60e7eb6 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/AttachmentController.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/AttachmentController.java @@ -21,9 +21,9 @@ import com.fsck.k9.Account; import com.fsck.k9.Preferences; import com.fsck.k9.controller.MessagingController; import com.fsck.k9.controller.SimpleMessagingListener; +import com.fsck.k9.helper.MimeTypeUtil; import com.fsck.k9.mail.Message; import com.fsck.k9.mail.Part; -import com.fsck.k9.mail.internet.MimeUtility; import com.fsck.k9.mailstore.AttachmentViewInfo; import com.fsck.k9.mailstore.LocalMessage; import com.fsck.k9.mailstore.LocalPart; @@ -117,7 +117,7 @@ public class AttachmentController { ContentResolver contentResolver = context.getContentResolver(); InputStream in = contentResolver.openInputStream(attachment.internalUri); try { - OutputStream out = contentResolver.openOutputStream(documentUri); + OutputStream out = contentResolver.openOutputStream(documentUri, "wt"); try { IOUtils.copy(in, out); out.flush(); @@ -140,11 +140,11 @@ public class AttachmentController { } String displayName = attachment.displayName; - String inferredMimeType = MimeUtility.getMimeTypeByExtension(displayName); + String inferredMimeType = MimeTypeUtil.getMimeTypeByExtension(displayName); IntentAndResolvedActivitiesCount resolvedIntentInfo; String mimeType = attachment.mimeType; - if (MimeUtility.isDefaultMimeType(mimeType)) { + if (MimeTypeUtil.isDefaultMimeType(mimeType)) { resolvedIntentInfo = getViewIntentForMimeType(intentDataUri, inferredMimeType); } else { resolvedIntentInfo = getViewIntentForMimeType(intentDataUri, mimeType); @@ -154,7 +154,7 @@ public class AttachmentController { } if (!resolvedIntentInfo.hasResolvedActivities()) { - resolvedIntentInfo = getViewIntentForMimeType(intentDataUri, MimeUtility.DEFAULT_ATTACHMENT_MIME_TYPE); + resolvedIntentInfo = getViewIntentForMimeType(intentDataUri, MimeTypeUtil.DEFAULT_ATTACHMENT_MIME_TYPE); } return resolvedIntentInfo.getIntent(); 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 d38cbab8c146cdcb6917ad3c0caebfa9b88e3eca..e1ce84d49d4e084b78e028ad9cf0c34e7c655534 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 @@ -36,6 +36,9 @@ import com.fsck.k9.Account; import com.fsck.k9.DI; import com.fsck.k9.K9; import com.fsck.k9.Preferences; +import com.fsck.k9.activity.MessageCompose; +import com.fsck.k9.helper.MailtoUnsubscribeUri; +import com.fsck.k9.helper.UnsubscribeUri; import com.fsck.k9.ui.choosefolder.ChooseFolderActivity; import com.fsck.k9.activity.MessageLoaderHelper; import com.fsck.k9.activity.MessageLoaderHelper.MessageLoaderCallbacks; @@ -96,6 +99,7 @@ public class MessageViewFragment extends Fragment implements ConfirmationDialogF private MessageLoaderHelper messageLoaderHelper; private MessageCryptoPresenter messageCryptoPresenter; private Long showProgressThreshold; + private UnsubscribeUri preferredUnsubscribeUri; /** * Used to temporarily store the destination folder for refile operations if a confirmation @@ -655,6 +659,23 @@ public class MessageViewFragment extends Fragment implements ConfirmationDialogF return mMessageReference.getFolderId() != spamFolderId; } + public boolean canMessageBeUnsubscribed() { + return preferredUnsubscribeUri != null; + } + + public void onUnsubscribe() { + if (preferredUnsubscribeUri instanceof MailtoUnsubscribeUri) { + Intent intent = new Intent(mContext, MessageCompose.class); + intent.setAction(Intent.ACTION_VIEW); + intent.setData(preferredUnsubscribeUri.getUri()); + intent.putExtra(MessageCompose.EXTRA_ACCOUNT, mMessageReference.getAccountUuid()); + startActivity(intent); + } else { + Intent intent = new Intent(Intent.ACTION_VIEW, preferredUnsubscribeUri.getUri()); + startActivity(intent); + } + } + public Context getApplicationContext() { return mContext; } @@ -779,12 +800,14 @@ public class MessageViewFragment extends Fragment implements ConfirmationDialogF @Override public void onMessageViewInfoLoadFinished(MessageViewInfo messageViewInfo) { showMessage(messageViewInfo); + preferredUnsubscribeUri = messageViewInfo.preferredUnsubscribeUri; showProgressThreshold = null; } @Override public void onMessageViewInfoLoadFailed(MessageViewInfo messageViewInfo) { showMessage(messageViewInfo); + preferredUnsubscribeUri = null; showProgressThreshold = null; } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/OpenPgpAppSelectDialog.java b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/OpenPgpAppSelectDialog.java index ec01b6ece7d2ab47c71482d50faa9d3b6c50c8f1..d9045bc4fa590413cffe410e317abe32e3b3e886 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/OpenPgpAppSelectDialog.java +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/OpenPgpAppSelectDialog.java @@ -30,7 +30,6 @@ import com.fsck.k9.Preferences; import com.fsck.k9.ui.R; import com.fsck.k9.ui.base.K9Activity; import com.fsck.k9.ui.base.ThemeType; -import com.fsck.k9.ui.dialog.ApgDeprecationWarningDialog; import org.openintents.openpgp.util.OpenPgpApi; import org.openintents.openpgp.util.OpenPgpProviderUtil; import timber.log.Timber; @@ -40,11 +39,8 @@ public class OpenPgpAppSelectDialog extends K9Activity { private static final String EXTRA_ACCOUNT = "account"; private static final String OPENKEYCHAIN_PACKAGE = "org.sufficientlysecure.keychain"; - private static final String PACKAGE_NAME_APG = "org.thialfihar.android.apg"; - private static final String APG_PROVIDER_PLACEHOLDER = "apg-placeholder"; public static final String FRAG_OPENPGP_SELECT = "openpgp_select"; - public static final String FRAG_APG_DEPRECATE = "apg_deprecate"; public static final String FRAG_OPENKEYCHAIN_INFO = "openkeychain_info"; private static final Intent MARKET_INTENT = new Intent(Intent.ACTION_VIEW, Uri.parse( @@ -53,7 +49,6 @@ public class OpenPgpAppSelectDialog extends K9Activity { String.format("https://play.google.com/store/apps/details?id=%s", OPENKEYCHAIN_PACKAGE))); - private boolean isStopped; private Account account; public static void startOpenPgpChooserActivity(Context context, Account account) { @@ -76,7 +71,6 @@ public class OpenPgpAppSelectDialog extends K9Activity { @Override protected void onStart() { - isStopped = false; super.onStart(); List openPgpProviderPackages = OpenPgpProviderUtil.getOpenPgpProviderPackages(this); @@ -93,7 +87,6 @@ public class OpenPgpAppSelectDialog extends K9Activity { @Override protected void onStop() { - isStopped = true; super.onStop(); } @@ -102,11 +95,6 @@ public class OpenPgpAppSelectDialog extends K9Activity { fragment.show(getSupportFragmentManager(), FRAG_OPENPGP_SELECT); } - private void showApgDeprecationDialogFragment() { - ApgDeprecationDialogFragment fragment = new ApgDeprecationDialogFragment(); - fragment.show(getSupportFragmentManager(), FRAG_APG_DEPRECATE); - } - private void showOpenKeychainInfoFragment() { OpenKeychainInfoFragment fragment = new OpenKeychainInfoFragment(); fragment.show(getSupportFragmentManager(), FRAG_OPENKEYCHAIN_INFO); @@ -126,12 +114,6 @@ public class OpenPgpAppSelectDialog extends K9Activity { getResources().getDrawable(R.drawable.ic_action_cancel_launchersize_light)); openPgpProviderList.add(noneEntry); - if (isApgInstalled(getActivity())) { - Drawable icon = getResources().getDrawable(R.drawable.ic_apg_small); - openPgpProviderList.add(new OpenPgpProviderEntry( - APG_PROVIDER_PLACEHOLDER, getString(R.string.apg), icon)); - } - // search for OpenPGP providers... Intent intent = new Intent(OpenPgpApi.SERVICE_INTENT_2); List resInfo = getActivity().getPackageManager().queryIntentServices(intent, 0); @@ -170,13 +152,6 @@ public class OpenPgpAppSelectDialog extends K9Activity { } } - private boolean isApgInstalled(Context context) { - Intent intent = new Intent("org.openintents.openpgp.IOpenPgpService"); - intent.setPackage(PACKAGE_NAME_APG); - List resInfo = context.getPackageManager().queryIntentServices(intent, 0); - return resInfo != null && !resInfo.isEmpty(); - } - @Override public void onStop() { super.onStop(); @@ -251,27 +226,6 @@ public class OpenPgpAppSelectDialog extends K9Activity { } } - public static class ApgDeprecationDialogFragment extends DialogFragment { - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - return new ApgDeprecationWarningDialog(getActivity()); - } - - @Override - public void onStop() { - super.onStop(); - - dismiss(); - } - - @Override - public void onDismiss(DialogInterface dialog) { - super.onDismiss(dialog); - - ((OpenPgpAppSelectDialog) getActivity()).onDismissApgDialog(); - } - } - public static class OpenKeychainInfoFragment extends DialogFragment { @NonNull @Override @@ -323,11 +277,6 @@ public class OpenPgpAppSelectDialog extends K9Activity { } public void onSelectProvider(String selectedPackage) { - if (APG_PROVIDER_PLACEHOLDER.equals(selectedPackage)) { - showApgDeprecationDialogFragment(); - return; - } - persistOpenPgpProviderSetting(selectedPackage); finish(); } @@ -337,12 +286,6 @@ public class OpenPgpAppSelectDialog extends K9Activity { Preferences.getPreferences(getApplicationContext()).saveAccount(account); } - public void onDismissApgDialog() { - if (!isStopped) { - showOpenPgpSelectDialogFragment(); - } - } - private static class OpenPgpProviderEntry { private String packageName; private String simpleName; diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/AccountActivator.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/AccountActivator.kt index f9f3ed2627dfa15d744d3a8f2b5490faee99c5c6..e7de2f3dc834164ef6ea5c94aae8af5236752adf 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/AccountActivator.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/AccountActivator.kt @@ -18,7 +18,16 @@ class AccountActivator( val account = preferences.getAccount(accountUuid) ?: error("Account $accountUuid not found") setAccountPasswords(account, incomingServerPassword, outgoingServerPassword) + enableAccount(account) + } + + fun enableAccount(accountUuid: String) { + val account = preferences.getAccount(accountUuid) ?: error("Account $accountUuid not found") + + enableAccount(account) + } + private fun enableAccount(account: Account) { // Start services if necessary Core.setServicesEnabled(context) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/SettingsImportFragment.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/SettingsImportFragment.kt index 6a7260756ef7c279011a6077dd5fde61be8bb78b..330b1b5711078c250ea235a5917eef507283f83d 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/SettingsImportFragment.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/SettingsImportFragment.kt @@ -14,6 +14,7 @@ import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.RecyclerView +import com.fsck.k9.activity.setup.OAuthFlowActivity import com.fsck.k9.ui.R import com.fsck.k9.ui.observeNotNull import com.mikepenz.fastadapter.FastAdapter @@ -103,6 +104,12 @@ class SettingsImportFragment : Fragment() { StatusText.IMPORT_SUCCESS_PASSWORD_REQUIRED -> { statusText.text = getString(R.string.settings_import_password_required) } + StatusText.IMPORT_SUCCESS_AUTHORIZATION_REQUIRED -> { + statusText.text = getString(R.string.settings_import_authorization_required) + } + StatusText.IMPORT_SUCCESS_PASSWORD_AND_AUTHORIZATION_REQUIRED -> { + statusText.text = getString(R.string.settings_import_authorization_and_password_required) + } StatusText.IMPORT_READ_FAILURE -> { statusText.text = getString(R.string.settings_import_read_failure) } @@ -142,6 +149,7 @@ class SettingsImportFragment : Fragment() { is Action.Close -> closeImportScreen(action) is Action.PickDocument -> pickDocument() is Action.PasswordPrompt -> showPasswordPrompt(action) + is Action.StartAuthorization -> startAuthorization(action) } } @@ -160,6 +168,15 @@ class SettingsImportFragment : Fragment() { startActivityForResult(createDocumentIntent, REQUEST_PICK_DOCUMENT) } + private fun startAuthorization(action: Action.StartAuthorization) { + val intent = OAuthFlowActivity.buildLaunchIntent( + context = requireContext(), + accountUuid = action.accountUuid + ) + + startActivityForResult(intent, REQUEST_AUTHORIZATION) + } + private fun showPasswordPrompt(action: Action.PasswordPrompt) { val dialogFragment = PasswordPromptDialogFragment.create( action.accountUuid, @@ -183,6 +200,7 @@ class SettingsImportFragment : Fragment() { when (requestCode) { REQUEST_PICK_DOCUMENT -> handlePickDocumentResult(resultCode, data) REQUEST_PASSWORD_PROMPT -> handlePasswordPromptResult(resultCode, data) + REQUEST_AUTHORIZATION -> handleAuthorizationResult(resultCode) } } @@ -203,9 +221,16 @@ class SettingsImportFragment : Fragment() { } } + private fun handleAuthorizationResult(resultCode: Int) { + if (resultCode == Activity.RESULT_OK) { + viewModel.onReturnAfterAuthorization() + } + } + companion object { private const val REQUEST_PICK_DOCUMENT = Activity.RESULT_FIRST_USER private const val REQUEST_PASSWORD_PROMPT = Activity.RESULT_FIRST_USER + 1 + private const val REQUEST_AUTHORIZATION = Activity.RESULT_FIRST_USER + 2 } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/SettingsImportListItems.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/SettingsImportListItems.kt index 71ac7d88d479fbc2ce2388f1c242700154bd3664..93f727a730bc224e719c2a6f36b8a3503a6b40ae 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/SettingsImportListItems.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/SettingsImportListItems.kt @@ -32,8 +32,9 @@ abstract class ImportListItem( val imageLevel = when (importStatus) { ImportStatus.IMPORT_SUCCESS -> 0 ImportStatus.IMPORT_SUCCESS_PASSWORD_REQUIRED -> 1 - ImportStatus.NOT_SELECTED -> 2 - ImportStatus.IMPORT_FAILURE -> 3 + ImportStatus.IMPORT_SUCCESS_AUTHORIZATION_REQUIRED -> 2 + ImportStatus.NOT_SELECTED -> 3 + ImportStatus.IMPORT_FAILURE -> 4 else -> error("Unexpected import status: $importStatus") } holder.statusIcon.setImageLevel(imageLevel) @@ -41,6 +42,7 @@ abstract class ImportListItem( val contentDescriptionStringResId = when (importStatus) { ImportStatus.IMPORT_SUCCESS -> R.string.settings_import_status_success ImportStatus.IMPORT_SUCCESS_PASSWORD_REQUIRED -> R.string.settings_import_status_password_required + ImportStatus.IMPORT_SUCCESS_AUTHORIZATION_REQUIRED -> R.string.settings_import_status_log_in_required ImportStatus.NOT_SELECTED -> R.string.settings_import_status_not_imported ImportStatus.IMPORT_FAILURE -> R.string.settings_import_status_error else -> error("Unexpected import status: $importStatus") diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/SettingsImportUiModel.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/SettingsImportUiModel.kt index 0c990550241b9a563670c4e74c125740a0736d83..817aed1c5df664964653c6adce8c3fa066408b7a 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/SettingsImportUiModel.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/SettingsImportUiModel.kt @@ -67,13 +67,13 @@ class SettingsImportUiModel { statusText = StatusText.IMPORT_SUCCESS } - private fun showPasswordRequiredText() { + private fun showActionRequiredText(actionText: StatusText) { importButton = ButtonState.GONE closeButton = ButtonState.ENABLED closeButtonLabel = CloseButtonLabel.LATER isImportProgressVisible = false isSettingsListEnabled = true - statusText = StatusText.IMPORT_SUCCESS_PASSWORD_REQUIRED + statusText = actionText } fun showReadFailureText() { @@ -120,7 +120,7 @@ class SettingsImportUiModel { fun setSettingsListState(position: Int, status: ImportStatus) { settingsList[position].importStatus = status - settingsList[position].enabled = status == ImportStatus.IMPORT_SUCCESS_PASSWORD_REQUIRED + settingsList[position].enabled = status.isActionRequired } private fun updateImportButtonFromSelection() { @@ -141,17 +141,26 @@ class SettingsImportUiModel { return } - val passwordsMissing = settingsList.any { it.importStatus == ImportStatus.IMPORT_SUCCESS_PASSWORD_REQUIRED } - if (passwordsMissing) { - showPasswordRequiredText() - return + val passwordsMissing = settingsList.any { + it.importStatus == ImportStatus.IMPORT_SUCCESS_PASSWORD_REQUIRED + } + val authorizationRequired = settingsList.any { + it.importStatus == ImportStatus.IMPORT_SUCCESS_AUTHORIZATION_REQUIRED } - val partialImportError = settingsList.any { it.importStatus == ImportStatus.IMPORT_FAILURE } - if (partialImportError) { - showPartialImportErrorText() + if (passwordsMissing && authorizationRequired) { + showActionRequiredText(StatusText.IMPORT_SUCCESS_PASSWORD_AND_AUTHORIZATION_REQUIRED) + } else if (passwordsMissing) { + showActionRequiredText(StatusText.IMPORT_SUCCESS_PASSWORD_REQUIRED) + } else if (authorizationRequired) { + showActionRequiredText(StatusText.IMPORT_SUCCESS_AUTHORIZATION_REQUIRED) } else { - showSuccessText() + val partialImportError = settingsList.any { it.importStatus == ImportStatus.IMPORT_FAILURE } + if (partialImportError) { + showPartialImportErrorText() + } else { + showSuccessText() + } } } } @@ -165,15 +174,13 @@ sealed class SettingsListItem { class Account(val accountIndex: Int, var displayName: String) : SettingsListItem() } -enum class ImportStatus { - NOT_AVAILABLE, - NOT_SELECTED, - IMPORT_SUCCESS, - IMPORT_SUCCESS_PASSWORD_REQUIRED, - IMPORT_FAILURE; - - val isSuccess: Boolean - get() = this == IMPORT_SUCCESS || this == IMPORT_SUCCESS_PASSWORD_REQUIRED +enum class ImportStatus(val isSuccess: Boolean, val isActionRequired: Boolean) { + NOT_AVAILABLE(isSuccess = false, isActionRequired = false), + NOT_SELECTED(isSuccess = false, isActionRequired = false), + IMPORT_SUCCESS(isSuccess = true, isActionRequired = false), + IMPORT_SUCCESS_PASSWORD_REQUIRED(isSuccess = true, isActionRequired = true), + IMPORT_SUCCESS_AUTHORIZATION_REQUIRED(isSuccess = true, isActionRequired = true), + IMPORT_FAILURE(isSuccess = false, isActionRequired = false) } enum class ButtonState { @@ -188,6 +195,8 @@ enum class StatusText { IMPORTING_PROGRESS, IMPORT_SUCCESS, IMPORT_SUCCESS_PASSWORD_REQUIRED, + IMPORT_SUCCESS_AUTHORIZATION_REQUIRED, + IMPORT_SUCCESS_PASSWORD_AND_AUTHORIZATION_REQUIRED, IMPORT_READ_FAILURE, IMPORT_PARTIAL_FAILURE, IMPORT_FAILURE diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/SettingsImportViewModel.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/SettingsImportViewModel.kt index e6a456de4cc115e6e184f3439688a7d8ffed865e..35c744d2a457dbbe93dc809aaada8b8c11968b4c 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/SettingsImportViewModel.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/settings/import/SettingsImportViewModel.kt @@ -36,6 +36,7 @@ class SettingsImportViewModel( private var accountsMap: MutableMap = mutableMapOf() private val accountStateMap: MutableMap = mutableMapOf() private var contentUri: Uri? = null + private var currentlyAuthorizingAccountUuid: String? = null private val containsGeneralSettings: Boolean get() = uiModel.settingsList.any { it is SettingsListItem.GeneralSettings } @@ -69,6 +70,7 @@ class SettingsImportViewModel( fun initializeFromSavedState(savedInstanceState: Bundle) { contentUri = savedInstanceState.getParcelable(STATE_CONTENT_URI) + currentlyAuthorizingAccountUuid = savedInstanceState.getString(STATE_CURRENTLY_AUTHORIZING_ACCOUNT_UUID) updateUiModel { isSettingsListVisible = savedInstanceState.getBoolean(STATE_SETTINGS_LIST_VISIBLE) @@ -145,6 +147,7 @@ class SettingsImportViewModel( outState.putBoolean(STATE_LOADING_PROGRESS_VISIBLE, isLoadingProgressVisible) outState.putBoolean(STATE_IMPORT_PROGRESS_VISIBLE, isImportProgressVisible) outState.putEnum(STATE_STATUS_TEXT, statusText) + outState.putString(STATE_CURRENTLY_AUTHORIZING_ACCOUNT_UUID, currentlyAuthorizingAccountUuid) if (hasDocumentBeenRead) { val containsGeneralSettings = this@SettingsImportViewModel.containsGeneralSettings @@ -200,6 +203,9 @@ class SettingsImportViewModel( ImportStatus.NOT_AVAILABLE -> updateUiModel { toggleSettingsListItemSelection(position) } + ImportStatus.IMPORT_SUCCESS_AUTHORIZATION_REQUIRED -> { + startAuthorization(settingsListItem as SettingsListItem.Account) + } ImportStatus.IMPORT_SUCCESS_PASSWORD_REQUIRED -> { showPasswordPromptDialog(settingsListItem as SettingsListItem.Account) } @@ -222,6 +228,21 @@ class SettingsImportViewModel( } } + fun onReturnAfterAuthorization() { + currentlyAuthorizingAccountUuid?.let { accountUuid -> + currentlyAuthorizingAccountUuid = null + updateUiModel { + val index = getListIndexOfAccount(accountUuid) + setSettingsListState(index, ImportStatus.IMPORT_SUCCESS) + updateCloseButtonAndImportStatusText() + } + + GlobalScope.launch(Dispatchers.IO) { + accountActivator.enableAccount(accountUuid) + } + } + } + private fun getListIndexOfAccount(accountUuid: String): Int { return uiModel.settingsList.indexOfFirst { it is SettingsListItem.Account && accountsMap[it.accountIndex] == accountUuid @@ -333,7 +354,9 @@ class SettingsImportViewModel( accountsMap[accountIndex] = accountPair.imported.uuid listItem.displayName = accountPair.imported.name - if (accountPair.incomingPasswordNeeded || accountPair.outgoingPasswordNeeded) { + if (accountPair.authorizationNeeded) { + setSettingsListState(index, ImportStatus.IMPORT_SUCCESS_AUTHORIZATION_REQUIRED) + } else if (accountPair.incomingPasswordNeeded || accountPair.outgoingPasswordNeeded) { accountStateMap[accountIndex] = AccountState( accountPair.incomingServerName, accountPair.outgoingServerName, @@ -364,6 +387,14 @@ class SettingsImportViewModel( } } + private fun startAuthorization(settingsListItem: SettingsListItem.Account) { + val accountIndex = settingsListItem.accountIndex + val accountUuid = accountsMap[accountIndex]!! + currentlyAuthorizingAccountUuid = accountUuid + + sendActionEvent(Action.StartAuthorization(accountUuid)) + } + private fun showPasswordPromptDialog(settingsListItem: SettingsListItem.Account) { val accountIndex = settingsListItem.accountIndex @@ -431,12 +462,14 @@ class SettingsImportViewModel( private const val STATE_CONTENT_URI = "contentUri" private const val STATE_GENERAL_SETTINGS_IMPORT_STATUS = "generalSettingsImportStatus" private const val STATE_ACCOUNT_LIST = "accountList" + private const val STATE_CURRENTLY_AUTHORIZING_ACCOUNT_UUID = "currentlyAuthorizingAccountUuid" } } sealed class Action { class Close(val importSuccess: Boolean) : Action() object PickDocument : Action() + class StartAuthorization(val accountUuid: String) : Action() class PasswordPrompt( val accountUuid: String, val accountName: String, diff --git a/app/ui/legacy/src/main/res/drawable-hdpi/btn_google_signin_dark_disabled.9.png b/app/ui/legacy/src/main/res/drawable-hdpi/btn_google_signin_dark_disabled.9.png new file mode 100644 index 0000000000000000000000000000000000000000..082201ea687e1ddc8fdf5bd7b549432872046be0 Binary files /dev/null and b/app/ui/legacy/src/main/res/drawable-hdpi/btn_google_signin_dark_disabled.9.png differ diff --git a/app/ui/legacy/src/main/res/drawable-hdpi/btn_google_signin_dark_focus.9.png b/app/ui/legacy/src/main/res/drawable-hdpi/btn_google_signin_dark_focus.9.png new file mode 100644 index 0000000000000000000000000000000000000000..2a3c8937a1f8a7c175d6d145ed49977d1fc9f4ef Binary files /dev/null and b/app/ui/legacy/src/main/res/drawable-hdpi/btn_google_signin_dark_focus.9.png differ diff --git a/app/ui/legacy/src/main/res/drawable-hdpi/btn_google_signin_dark_normal.9.png b/app/ui/legacy/src/main/res/drawable-hdpi/btn_google_signin_dark_normal.9.png new file mode 100644 index 0000000000000000000000000000000000000000..4a5ddfb762a13bd5f52b970697e7d26ddfd9b26f Binary files /dev/null and b/app/ui/legacy/src/main/res/drawable-hdpi/btn_google_signin_dark_normal.9.png differ diff --git a/app/ui/legacy/src/main/res/drawable-hdpi/btn_google_signin_dark_pressed.9.png b/app/ui/legacy/src/main/res/drawable-hdpi/btn_google_signin_dark_pressed.9.png new file mode 100644 index 0000000000000000000000000000000000000000..82d16422c70ce338ca08e1ff1ead75b3fd519c06 Binary files /dev/null and b/app/ui/legacy/src/main/res/drawable-hdpi/btn_google_signin_dark_pressed.9.png differ diff --git a/app/ui/legacy/src/main/res/drawable-hdpi/ic_apg_small.png b/app/ui/legacy/src/main/res/drawable-hdpi/ic_apg_small.png deleted file mode 100644 index 29b41d89d2b6c1e4f60460a992a871640c5e8fc8..0000000000000000000000000000000000000000 Binary files a/app/ui/legacy/src/main/res/drawable-hdpi/ic_apg_small.png and /dev/null differ diff --git a/app/ui/legacy/src/main/res/drawable-mdpi/btn_google_signin_dark_disabled.9.png b/app/ui/legacy/src/main/res/drawable-mdpi/btn_google_signin_dark_disabled.9.png new file mode 100644 index 0000000000000000000000000000000000000000..b88019ed25141eec5e9a1c1fcd3f9c08fc42266f Binary files /dev/null and b/app/ui/legacy/src/main/res/drawable-mdpi/btn_google_signin_dark_disabled.9.png differ diff --git a/app/ui/legacy/src/main/res/drawable-mdpi/btn_google_signin_dark_focus.9.png b/app/ui/legacy/src/main/res/drawable-mdpi/btn_google_signin_dark_focus.9.png new file mode 100644 index 0000000000000000000000000000000000000000..5265221ad9a227347b3ab8ae17e4995c6ef10ba0 Binary files /dev/null and b/app/ui/legacy/src/main/res/drawable-mdpi/btn_google_signin_dark_focus.9.png differ diff --git a/app/ui/legacy/src/main/res/drawable-mdpi/btn_google_signin_dark_normal.9.png b/app/ui/legacy/src/main/res/drawable-mdpi/btn_google_signin_dark_normal.9.png new file mode 100644 index 0000000000000000000000000000000000000000..6b601919ded34b833741aa172e5e343e43991bc9 Binary files /dev/null and b/app/ui/legacy/src/main/res/drawable-mdpi/btn_google_signin_dark_normal.9.png differ diff --git a/app/ui/legacy/src/main/res/drawable-mdpi/btn_google_signin_dark_pressed.9.png b/app/ui/legacy/src/main/res/drawable-mdpi/btn_google_signin_dark_pressed.9.png new file mode 100644 index 0000000000000000000000000000000000000000..892674099cf6267f937baed8a8d3603153e0362a Binary files /dev/null and b/app/ui/legacy/src/main/res/drawable-mdpi/btn_google_signin_dark_pressed.9.png differ diff --git a/app/ui/legacy/src/main/res/drawable-mdpi/ic_apg_small.png b/app/ui/legacy/src/main/res/drawable-mdpi/ic_apg_small.png deleted file mode 100644 index eec5d0ae18e3ec5d655d21f1e4c90ceedb70f34c..0000000000000000000000000000000000000000 Binary files a/app/ui/legacy/src/main/res/drawable-mdpi/ic_apg_small.png and /dev/null differ diff --git a/app/ui/legacy/src/main/res/drawable-xhdpi/btn_google_signin_dark_disabled.9.png b/app/ui/legacy/src/main/res/drawable-xhdpi/btn_google_signin_dark_disabled.9.png new file mode 100644 index 0000000000000000000000000000000000000000..5cac64fe76c7098f39f69e3a36a6f0e6a4b4eb21 Binary files /dev/null and b/app/ui/legacy/src/main/res/drawable-xhdpi/btn_google_signin_dark_disabled.9.png differ diff --git a/app/ui/legacy/src/main/res/drawable-xhdpi/btn_google_signin_dark_focus.9.png b/app/ui/legacy/src/main/res/drawable-xhdpi/btn_google_signin_dark_focus.9.png new file mode 100644 index 0000000000000000000000000000000000000000..bd52cae7c60677c460aa10e39223bf61aeb4af09 Binary files /dev/null and b/app/ui/legacy/src/main/res/drawable-xhdpi/btn_google_signin_dark_focus.9.png differ diff --git a/app/ui/legacy/src/main/res/drawable-xhdpi/btn_google_signin_dark_normal.9.png b/app/ui/legacy/src/main/res/drawable-xhdpi/btn_google_signin_dark_normal.9.png new file mode 100644 index 0000000000000000000000000000000000000000..e48e5a278c153964be6ea06268b884951dce5b1c Binary files /dev/null and b/app/ui/legacy/src/main/res/drawable-xhdpi/btn_google_signin_dark_normal.9.png differ diff --git a/app/ui/legacy/src/main/res/drawable-xhdpi/btn_google_signin_dark_pressed.9.png b/app/ui/legacy/src/main/res/drawable-xhdpi/btn_google_signin_dark_pressed.9.png new file mode 100644 index 0000000000000000000000000000000000000000..b1ed3e21646838490e948febb300fcebbc958816 Binary files /dev/null and b/app/ui/legacy/src/main/res/drawable-xhdpi/btn_google_signin_dark_pressed.9.png differ diff --git a/app/ui/legacy/src/main/res/drawable-xhdpi/ic_apg_small.png b/app/ui/legacy/src/main/res/drawable-xhdpi/ic_apg_small.png deleted file mode 100644 index 7ca53f0ec6a4648b0468160c970a3445e2f7eb31..0000000000000000000000000000000000000000 Binary files a/app/ui/legacy/src/main/res/drawable-xhdpi/ic_apg_small.png and /dev/null differ diff --git a/app/ui/legacy/src/main/res/drawable-xxhdpi/btn_google_signin_dark_disabled.9.png b/app/ui/legacy/src/main/res/drawable-xxhdpi/btn_google_signin_dark_disabled.9.png new file mode 100644 index 0000000000000000000000000000000000000000..545fb14327619f5927b32732a3d36935a950cf41 Binary files /dev/null and b/app/ui/legacy/src/main/res/drawable-xxhdpi/btn_google_signin_dark_disabled.9.png differ diff --git a/app/ui/legacy/src/main/res/drawable-xxhdpi/btn_google_signin_dark_focus.9.png b/app/ui/legacy/src/main/res/drawable-xxhdpi/btn_google_signin_dark_focus.9.png new file mode 100644 index 0000000000000000000000000000000000000000..6bc828acb4f1e794d9f290700a51c5495d93e149 Binary files /dev/null and b/app/ui/legacy/src/main/res/drawable-xxhdpi/btn_google_signin_dark_focus.9.png differ diff --git a/app/ui/legacy/src/main/res/drawable-xxhdpi/btn_google_signin_dark_normal.9.png b/app/ui/legacy/src/main/res/drawable-xxhdpi/btn_google_signin_dark_normal.9.png new file mode 100644 index 0000000000000000000000000000000000000000..3fb4c67209d81d6656330093789f4d95dac5850e Binary files /dev/null and b/app/ui/legacy/src/main/res/drawable-xxhdpi/btn_google_signin_dark_normal.9.png differ diff --git a/app/ui/legacy/src/main/res/drawable-xxhdpi/btn_google_signin_dark_pressed.9.png b/app/ui/legacy/src/main/res/drawable-xxhdpi/btn_google_signin_dark_pressed.9.png new file mode 100644 index 0000000000000000000000000000000000000000..c5096f73abefd194f9447bdee55b829de483b97f Binary files /dev/null and b/app/ui/legacy/src/main/res/drawable-xxhdpi/btn_google_signin_dark_pressed.9.png differ diff --git a/app/ui/legacy/src/main/res/drawable-xxhdpi/ic_apg_small.png b/app/ui/legacy/src/main/res/drawable-xxhdpi/ic_apg_small.png deleted file mode 100644 index 68df5fb5806ff996276eb2c7076a8b4d6dfc8e03..0000000000000000000000000000000000000000 Binary files a/app/ui/legacy/src/main/res/drawable-xxhdpi/ic_apg_small.png and /dev/null differ diff --git a/app/ui/legacy/src/main/res/drawable-xxxhdpi/ic_apg_small.png b/app/ui/legacy/src/main/res/drawable-xxxhdpi/ic_apg_small.png deleted file mode 100644 index de98c497b386f8724294bbe4f57657d79cf117fd..0000000000000000000000000000000000000000 Binary files a/app/ui/legacy/src/main/res/drawable-xxxhdpi/ic_apg_small.png and /dev/null differ diff --git a/app/ui/legacy/src/main/res/drawable/btn_google_signin_dark.xml b/app/ui/legacy/src/main/res/drawable/btn_google_signin_dark.xml new file mode 100644 index 0000000000000000000000000000000000000000..bb0e15af7d57dce1ca8800be45fe44b289130d95 --- /dev/null +++ b/app/ui/legacy/src/main/res/drawable/btn_google_signin_dark.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/ui/legacy/src/main/res/drawable/ic_import_status.xml b/app/ui/legacy/src/main/res/drawable/ic_import_status.xml index e3d0dd694b5296e3b5d053074ee3a9d4bb8c48d9..6ce5727bdd65cbd36453bb42aec585cc19ac69dc 100644 --- a/app/ui/legacy/src/main/res/drawable/ic_import_status.xml +++ b/app/ui/legacy/src/main/res/drawable/ic_import_status.xml @@ -9,11 +9,15 @@ android:maxLevel="1" android:minLevel="1" /> + diff --git a/app/ui/legacy/src/main/res/drawable/ic_login.xml b/app/ui/legacy/src/main/res/drawable/ic_login.xml new file mode 100644 index 0000000000000000000000000000000000000000..8bee0a7de90754a0b0fe2019bec1575ee8f73da3 --- /dev/null +++ b/app/ui/legacy/src/main/res/drawable/ic_login.xml @@ -0,0 +1,10 @@ + + + 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 f0cf0371a7d02eeef95020e337ef1fa14803d490..f88622f7f4c1e0c99774906e8021d666c0d3fbb0 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 @@ -37,7 +37,7 @@ 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 38bb5202d690ef60ba020e1850f9d1f722eb925c..3807ce8c82e96f1c51ea78f3430233503ecdae1d 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 @@ -247,43 +247,11 @@ - - - - - - - - - - + android:layout_width="wrap_content" + android:text="@string/account_setup_incoming_use_compression" /> + + + + + + + + + + +