Repo created
This commit is contained in:
parent
a629de6271
commit
3cef7c5092
2161 changed files with 246605 additions and 2 deletions
9
app/autodiscovery/api/build.gradle.kts
Normal file
9
app/autodiscovery/api/build.gradle.kts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
@Suppress("DSL_SCOPE_VIOLATION")
|
||||
plugins {
|
||||
id(ThunderbirdPlugins.Library.jvm)
|
||||
alias(libs.plugins.android.lint)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.mail.common)
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.fsck.k9.autodiscovery.api
|
||||
|
||||
import com.fsck.k9.mail.AuthType
|
||||
import com.fsck.k9.mail.ConnectionSecurity
|
||||
|
||||
interface ConnectionSettingsDiscovery {
|
||||
fun discover(email: String): DiscoveryResults?
|
||||
}
|
||||
|
||||
data class DiscoveryResults(val incoming: List<DiscoveredServerSettings>, val outgoing: List<DiscoveredServerSettings>)
|
||||
|
||||
data class DiscoveredServerSettings(
|
||||
val protocol: String,
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val security: ConnectionSecurity,
|
||||
val authType: AuthType?,
|
||||
val username: String?
|
||||
)
|
||||
20
app/autodiscovery/providersxml/build.gradle.kts
Normal file
20
app/autodiscovery/providersxml/build.gradle.kts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.android)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.app.core)
|
||||
implementation(projects.mail.common)
|
||||
implementation(projects.app.autodiscovery.api)
|
||||
|
||||
implementation(libs.timber)
|
||||
|
||||
testImplementation(projects.app.testing)
|
||||
testImplementation(projects.backend.imap)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.androidx.test.core)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.fsck.k9.autodiscovery.providersxml"
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.fsck.k9.autodiscovery.providersxml
|
||||
|
||||
import org.koin.dsl.module
|
||||
|
||||
val autodiscoveryProvidersXmlModule = module {
|
||||
factory { ProvidersXmlProvider(context = get()) }
|
||||
factory { ProvidersXmlDiscovery(xmlProvider = get(), oAuthConfigurationProvider = get()) }
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
package com.fsck.k9.autodiscovery.providersxml
|
||||
|
||||
import android.content.res.XmlResourceParser
|
||||
import android.net.Uri
|
||||
import com.fsck.k9.autodiscovery.api.ConnectionSettingsDiscovery
|
||||
import com.fsck.k9.autodiscovery.api.DiscoveredServerSettings
|
||||
import com.fsck.k9.autodiscovery.api.DiscoveryResults
|
||||
import com.fsck.k9.helper.EmailHelper
|
||||
import com.fsck.k9.mail.AuthType
|
||||
import com.fsck.k9.mail.ConnectionSecurity
|
||||
import com.fsck.k9.oauth.OAuthConfigurationProvider
|
||||
import com.fsck.k9.preferences.Protocols
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import timber.log.Timber
|
||||
|
||||
class ProvidersXmlDiscovery(
|
||||
private val xmlProvider: ProvidersXmlProvider,
|
||||
private val oAuthConfigurationProvider: OAuthConfigurationProvider
|
||||
) : ConnectionSettingsDiscovery {
|
||||
|
||||
override fun discover(email: String): DiscoveryResults? {
|
||||
val domain = EmailHelper.getDomainFromEmailAddress(email) ?: return null
|
||||
|
||||
val provider = findProviderForDomain(domain) ?: return null
|
||||
|
||||
val incomingSettings = provider.toIncomingServerSettings(email) ?: return null
|
||||
val outgoingSettings = provider.toOutgoingServerSettings(email) ?: return null
|
||||
return DiscoveryResults(listOf(incomingSettings), listOf(outgoingSettings))
|
||||
}
|
||||
|
||||
private fun findProviderForDomain(domain: String): Provider? {
|
||||
return try {
|
||||
xmlProvider.getXml().use { xml ->
|
||||
parseProviders(xml, domain)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error while trying to load provider settings.")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseProviders(xml: XmlResourceParser, domain: String): Provider? {
|
||||
do {
|
||||
val xmlEventType = xml.next()
|
||||
if (xmlEventType == XmlPullParser.START_TAG && xml.name == "provider") {
|
||||
val providerDomain = xml.getAttributeValue(null, "domain")
|
||||
if (domain.equals(providerDomain, ignoreCase = true)) {
|
||||
val provider = parseProvider(xml)
|
||||
if (provider != null) return provider
|
||||
}
|
||||
}
|
||||
} while (xmlEventType != XmlPullParser.END_DOCUMENT)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun parseProvider(xml: XmlResourceParser): Provider? {
|
||||
var incomingUriTemplate: String? = null
|
||||
var incomingUsernameTemplate: String? = null
|
||||
var outgoingUriTemplate: String? = null
|
||||
var outgoingUsernameTemplate: String? = null
|
||||
|
||||
do {
|
||||
val xmlEventType = xml.next()
|
||||
if (xmlEventType == XmlPullParser.START_TAG) {
|
||||
when (xml.name) {
|
||||
"incoming" -> {
|
||||
incomingUriTemplate = xml.getAttributeValue(null, "uri")
|
||||
incomingUsernameTemplate = xml.getAttributeValue(null, "username")
|
||||
}
|
||||
"outgoing" -> {
|
||||
outgoingUriTemplate = xml.getAttributeValue(null, "uri")
|
||||
outgoingUsernameTemplate = xml.getAttributeValue(null, "username")
|
||||
}
|
||||
}
|
||||
}
|
||||
} while (!(xmlEventType == XmlPullParser.END_TAG && xml.name == "provider"))
|
||||
|
||||
return if (incomingUriTemplate != null && incomingUsernameTemplate != null && outgoingUriTemplate != null &&
|
||||
outgoingUsernameTemplate != null
|
||||
) {
|
||||
Provider(incomingUriTemplate, incomingUsernameTemplate, outgoingUriTemplate, outgoingUsernameTemplate)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun Provider.toIncomingServerSettings(email: String): DiscoveredServerSettings? {
|
||||
val user = EmailHelper.getLocalPartFromEmailAddress(email) ?: return null
|
||||
val domain = EmailHelper.getDomainFromEmailAddress(email) ?: return null
|
||||
|
||||
val username = incomingUsernameTemplate.fillInUsernameTemplate(email, user, domain)
|
||||
|
||||
val security = when {
|
||||
incomingUriTemplate.startsWith("imap+ssl") -> ConnectionSecurity.SSL_TLS_REQUIRED
|
||||
incomingUriTemplate.startsWith("imap+tls") -> ConnectionSecurity.STARTTLS_REQUIRED
|
||||
else -> error("Connection security required")
|
||||
}
|
||||
|
||||
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
|
||||
} else {
|
||||
uri.port
|
||||
}
|
||||
|
||||
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 username = outgoingUsernameTemplate.fillInUsernameTemplate(email, user, domain)
|
||||
|
||||
val security = when {
|
||||
outgoingUriTemplate.startsWith("smtp+ssl") -> ConnectionSecurity.SSL_TLS_REQUIRED
|
||||
outgoingUriTemplate.startsWith("smtp+tls") -> ConnectionSecurity.STARTTLS_REQUIRED
|
||||
else -> error("Connection security required")
|
||||
}
|
||||
|
||||
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
|
||||
} else {
|
||||
uri.port
|
||||
}
|
||||
|
||||
val authType = if (oAuthConfigurationProvider.getConfiguration(host) != null) {
|
||||
AuthType.XOAUTH2
|
||||
} else {
|
||||
AuthType.PLAIN
|
||||
}
|
||||
|
||||
return DiscoveredServerSettings(Protocols.SMTP, host, port, security, authType, username)
|
||||
}
|
||||
|
||||
private fun String.fillInUsernameTemplate(email: String, user: String, domain: String): String {
|
||||
return this.replace("\$email", email).replace("\$user", user).replace("\$domain", domain)
|
||||
}
|
||||
|
||||
internal data class Provider(
|
||||
val incomingUriTemplate: String,
|
||||
val incomingUsernameTemplate: String,
|
||||
val outgoingUriTemplate: String,
|
||||
val outgoingUsernameTemplate: String
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.fsck.k9.autodiscovery.providersxml
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.XmlResourceParser
|
||||
|
||||
class ProvidersXmlProvider(private val context: Context) {
|
||||
fun getXml(): XmlResourceParser {
|
||||
return context.resources.getXml(R.xml.providers)
|
||||
}
|
||||
}
|
||||
774
app/autodiscovery/providersxml/src/main/res/xml/providers.xml
Normal file
774
app/autodiscovery/providersxml/src/main/res/xml/providers.xml
Normal file
|
|
@ -0,0 +1,774 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copyright (C) 2008 The Android Open Source Project
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<!--
|
||||
This file is used to specify providers that we know default settings for
|
||||
so that the user can set up their account by simply entering their email
|
||||
address and password.
|
||||
|
||||
When a user starts this process, the email address is parsed, the domain
|
||||
broken out and used to search this file for a provider. If one is found the
|
||||
provider's settings are used to attempt to connect to the account.
|
||||
|
||||
At this time, the id and label attributes are not used. However, please include them
|
||||
if you make edits to this file. id must also be completely unique. label will be shown
|
||||
to the user when there are multiple options provided for a single domain (not currently
|
||||
supported).
|
||||
|
||||
A provider contains the settings for setting up an email account
|
||||
that ends with the given domain. Domains should be unique within
|
||||
this file. Each provider should have at least one incoming section and
|
||||
one outgoing section. If more than one is specified only the first
|
||||
will be used.
|
||||
|
||||
Valid incoming uri schemes are:
|
||||
imap+tls+ IMAP with required TLS transport security.
|
||||
If TLS is not available the connection fails.
|
||||
imap+ssl+ IMAP with required SSL transport security.
|
||||
If SSL is not available the connection fails.
|
||||
|
||||
Valid outgoing uri schemes are:
|
||||
smtp+tls+ SMTP with required TLS transport security.
|
||||
If TLS is not available the connection fails.
|
||||
smtp+ssl+ SMTP with required SSL transport security.
|
||||
If SSL is not available the connection fails.
|
||||
|
||||
The URIs should be full templates for connection, including a port if
|
||||
the service uses a non-default port. The default ports are as follows:
|
||||
imap+tls+ 143 smtp+tls+ 587
|
||||
imap+ssl+ 993 smtp+ssl+ 465
|
||||
|
||||
The username attribute is used to supply a template for the username
|
||||
that will be presented to the server. This username is built from a
|
||||
set of variables that are substituted with parts of the user
|
||||
specified email address.
|
||||
|
||||
Valid substitution values for the username attribute are:
|
||||
$email - the email address the user entered
|
||||
$user - the value before the @ sign in the email address the user entered
|
||||
$domain - the value after the @ sign in the email address the user entered
|
||||
|
||||
The username attribute MUST be specified for the incoming element, so the IMAP
|
||||
server can identify the mailbox to be opened.
|
||||
|
||||
The username attribute MAY be the empty string for the outgoing element, but only if the
|
||||
SMTP server supports anonymous transmission (most don't).
|
||||
|
||||
While it would technically work please DO NOT add providers that don't support encrypted
|
||||
connections.
|
||||
-->
|
||||
|
||||
<providers>
|
||||
|
||||
<!-- Gmail variants -->
|
||||
<provider id="gmail" label="Gmail" domain="gmail.com">
|
||||
<incoming uri="imap+ssl+://imap.gmail.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.gmail.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="googlemail" label="Google Mail" domain="googlemail.com">
|
||||
<incoming uri="imap+ssl+://imap.googlemail.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.googlemail.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="google" label="Google" domain="google.com">
|
||||
<incoming uri="imap+ssl+://imap.gmail.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.gmail.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="android" label="Android" domain="android.com">
|
||||
<incoming uri="imap+ssl+://imap.gmail.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.gmail.com" username="$email" />
|
||||
</provider>
|
||||
|
||||
<!-- USA -->
|
||||
<provider id="comcast" label="Comcast" domain="comcast.net">
|
||||
<incoming uri="imap+ssl+://imap.comcast.net" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.comcast.net" username="$email" />
|
||||
</provider>
|
||||
<provider id="montclair.edu" label="MSU" domain="montclair.edu">
|
||||
<incoming uri="imap+ssl+://mail.montclair.edu" username="$user" />
|
||||
<outgoing uri="smtp+tls+://smtp.montclair.edu" username="$user" />
|
||||
</provider>
|
||||
<provider id="gmx.com" label="GMX" domain="gmx.com">
|
||||
<incoming uri="imap+ssl+://imap.gmx.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://mail.gmx.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="zoho.com" label="Zoho Mail" domain="zoho.com">
|
||||
<incoming uri="imap+ssl+://imap.zoho.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.zoho.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="riseup" label="Riseup Networks" domain="riseup.net">
|
||||
<incoming uri="imap+ssl+://mail.riseup.net" username="$user" />
|
||||
<outgoing uri="smtp+tls+://mail.riseup.net" username="$user" />
|
||||
</provider>
|
||||
|
||||
<!-- Mail.com Variants -->
|
||||
<provider id="mail.com" label="Mail.com" domain="mail.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="email.com" label="Mail.com" domain="email.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="techie.com" label="Mail.com" domain="techie.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="email.com" label="Mail.com" domain="email.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="usa.com" label="Mail.com" domain="usa.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="myself.com" label="Mail.com" domain="myself.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="consultant.com" label="Mail.com" domain="consultant.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="post.com" label="Mail.com" domain="post.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="europe.com" label="Mail.com" domain="europe.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="asia.com" label="Mail.com" domain="asia.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="iname.com" label="Mail.com" domain="iname.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="writeme.com" label="Mail.com" domain="writeme.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="dr.com" label="Mail.com" domain="dr.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="engineer.com" label="Mail.com" domain="engineer.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="cheerful.com" label="Mail.com" domain="cheerful.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="accountant.com" label="Mail.com" domain="accountant.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="techie.com" label="Mail.com" domain="techie.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="linuxmail.org" label="Mail.com" domain="linuxmail.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="uymail.com" label="Mail.com" domain="uymail.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="contractor.net" label="Mail.com" domain="contractor.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.com" username="$email" />
|
||||
</provider>
|
||||
|
||||
<!-- Yahoo! Mail Variants -->
|
||||
<provider id="yahoo" label="Yahoo" domain="yahoo.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.yahoo.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.yahoo.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="yahoo.de" label="Yahoo" domain="yahoo.de">
|
||||
<incoming uri="imap+ssl+://imap.mail.yahoo.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.yahoo.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="ymail" label="YMail" domain="ymail.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.yahoo.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.yahoo.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="rocketmail" label="Rocketmail" domain="rocketmail.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.yahoo.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.yahoo.com" username="$email" />
|
||||
</provider>
|
||||
|
||||
<!-- Apple -->
|
||||
<provider id="apple" label="Apple" domain="apple.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.apple.com" username="$user" />
|
||||
<outgoing uri="smtp+tls+://smtp.mail.apple.com" username="$user" />
|
||||
</provider>
|
||||
<provider id="dotmac" label=".Mac" domain="mac.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.mac.com" username="$user" />
|
||||
<outgoing uri="smtp+tls+://smtp.mail.mac.com" username="$user" />
|
||||
</provider>
|
||||
<provider id="mobileme" label="MobileMe" domain="me.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.me.com" username="$user" />
|
||||
<outgoing uri="smtp+tls+://smtp.mail.me.com" username="$user" />
|
||||
</provider>
|
||||
<provider id="icloud" label="iCloud" domain="icloud.com">
|
||||
<incoming uri="imap+ssl+://imap.mail.icloud.com" username="$user" />
|
||||
<outgoing uri="smtp+tls+://smtp.mail.icloud.com" username="$user" />
|
||||
</provider>
|
||||
|
||||
<!-- Australia -->
|
||||
<provider id="fastmail-fm" label="Fastmail" domain="fastmail.fm">
|
||||
<incoming uri="imap+ssl+://mail.messagingengine.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://mail.messagingengine.com" username="$email" />
|
||||
</provider>
|
||||
|
||||
<!-- Virgin Media variants -->
|
||||
<provider id="virginmedia.com" label="Virgin Media" domain="virginmedia.com">
|
||||
<incoming uri="imap+ssl+://imap.virginmedia.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.virginmedia.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="virgin.net" label="Virgin Media" domain="virgin.net">
|
||||
<incoming uri="imap+ssl+://imap.virginmedia.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.virginmedia.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="blueyonder.co.uk" label="Virgin Media" domain="blueyonder.co.uk">
|
||||
<incoming uri="imap+ssl+://imap.virginmedia.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.virginmedia.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="ntlworld.com" label="Virgin Media" domain="ntlworld.com">
|
||||
<incoming uri="imap+ssl+://imap.virginmedia.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.virginmedia.com" username="$email" />
|
||||
</provider>
|
||||
|
||||
<!-- France -->
|
||||
<provider id="mailo.com" label="mailo.com" domain="mailo.com">
|
||||
<incoming uri="imap+ssl+://mail.mailo.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://mail.mailo.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="net-c.fr" label="net-c.fr" domain="net-c.fr">
|
||||
<incoming uri="imap+ssl+://mail.mailo.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://mail.mailo.com" username="$email" />
|
||||
</provider>
|
||||
|
||||
<!-- Germany -->
|
||||
<provider id="mailbox.org" label="mailbox.org" domain="mailbox.org">
|
||||
<incoming uri="imap+tls+://imap.mailbox.org" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.mailbox.org" username="$email" />
|
||||
</provider>
|
||||
<provider id="freenet" label="Freenet" domain="freenet.de">
|
||||
<incoming uri="imap+tls+://mx.freenet.de" username="$email" />
|
||||
<outgoing uri="smtp+tls+://mx.freenet.de" username="$email" />
|
||||
</provider>
|
||||
<provider id="T-Online" label="T-Online" domain="t-online.de">
|
||||
<incoming uri="imap+ssl+://secureimap.t-online.de" username="$email" />
|
||||
<outgoing uri="smtp+tls+://securesmtp.t-online.de" username="$email" />
|
||||
</provider>
|
||||
<provider id="web.de" label="Web.de" domain="web.de">
|
||||
<incoming uri="imap+ssl+://imap.web.de" username="$user" />
|
||||
<outgoing uri="smtp+tls+://smtp.web.de" username="$user" />
|
||||
</provider>
|
||||
<provider id="posteo" label="Posteo" domain="posteo.net">
|
||||
<incoming uri="imap+tls+://posteo.de" username="$email" />
|
||||
<outgoing uri="smtp+tls+://posteo.de" username="$email" />
|
||||
</provider>
|
||||
<provider id="posteo" label="Posteo" domain="posteo.de">
|
||||
<incoming uri="imap+tls+://posteo.de" username="$email" />
|
||||
<outgoing uri="smtp+tls+://posteo.de" username="$email" />
|
||||
</provider>
|
||||
<provider id="systemliorg" label="systemli.org" domain="systemli.org">
|
||||
<incoming uri="imap+ssl+://mail.systemli.org" username="$email" />
|
||||
<outgoing uri="smtp+tls+://mail.systemli.org" username="$email" />
|
||||
</provider>
|
||||
|
||||
<!-- GMX variants -->
|
||||
<provider id="gmx.net" label="GMX.net" domain="gmx.net">
|
||||
<incoming uri="imap+ssl+://imap.gmx.net" username="$email" />
|
||||
<outgoing uri="smtp+tls+://mail.gmx.net" username="$email" />
|
||||
</provider>
|
||||
<provider id="gmx.de" label="GMX.de" domain="gmx.de">
|
||||
<incoming uri="imap+ssl+://imap.gmx.net" username="$email" />
|
||||
<outgoing uri="smtp+tls+://mail.gmx.net" username="$email" />
|
||||
</provider>
|
||||
<provider id="gmx.at" label="GMX.at" domain="gmx.at">
|
||||
<incoming uri="imap+ssl+://imap.gmx.net" username="$email" />
|
||||
<outgoing uri="smtp+tls+://mail.gmx.net" username="$email" />
|
||||
</provider>
|
||||
<provider id="gmx.ch" label="GMX.ch" domain="gmx.ch">
|
||||
<incoming uri="imap+ssl+://imap.gmx.net" username="$email" />
|
||||
<outgoing uri="smtp+tls+://mail.gmx.net" username="$email" />
|
||||
</provider>
|
||||
<provider id="gmx.eu" label="GMX.eu" domain="gmx.eu">
|
||||
<incoming uri="imap+ssl+://imap.gmx.net" username="$email" />
|
||||
<outgoing uri="smtp+tls+://mail.gmx.net" username="$email" />
|
||||
</provider>
|
||||
|
||||
<!-- Greece -->
|
||||
<provider id="otenet.gr" label="otenet.gr" domain="otenet.gr">
|
||||
<incoming uri="imap+ssl+://imap.otenet.gr" username="$email" />
|
||||
<outgoing uri="smtp+tls+://mailgate.otenet.gr" username="$email" />
|
||||
</provider>
|
||||
<provider id="cosmotemail.gr" label="cosmotemail" domain="cosmotemail.gr">
|
||||
<incoming uri="imap+ssl+://imap.cosmotemail.gr" username="$email" />
|
||||
<outgoing uri="smtp+tls+://mailgate.cosmotemail.gr" username="$email" />
|
||||
</provider>
|
||||
<provider id="mycosmos.gr" label="mycosmos" domain="mycosmos.gr">
|
||||
<incoming uri="imap+ssl+://mail.mycosmos.gr" username="$email" />
|
||||
<outgoing uri="smtp+tls+://mail.mycosmos.gr" username="$email" />
|
||||
</provider>
|
||||
<provider id="espiv" label="Espiv.net" domain="espiv.net">
|
||||
<incoming uri="imap+ssl+://mail.espiv.net" username="$email" />
|
||||
<outgoing uri="smtp+tls+://mail.espiv.net" username="$email" />
|
||||
</provider>
|
||||
<provider id="squat" label="Squat.gr" domain="squat.gr">
|
||||
<incoming uri="imap+ssl+://mail.espiv.net" username="$email" />
|
||||
<outgoing uri="smtp+tls+://mail.espiv.net" username="$email" />
|
||||
</provider>
|
||||
|
||||
<!-- Italy -->
|
||||
<provider id="poste" label="poste" domain="poste.it">
|
||||
<incoming uri="imap+ssl+://relay.poste.it" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://relay.poste.it" username="$email" />
|
||||
</provider>
|
||||
<provider id="vodafone" label="vodafone" domain="vodafone.it">
|
||||
<incoming uri="imap+ssl+://imap.vodafone.it" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.vodafone.it" username="$email" />
|
||||
</provider>
|
||||
|
||||
<!-- Switzerland -->
|
||||
<!-- KolabNow.com variants -->
|
||||
<provider id="kolabnow.com" label="KolabNow.com" domain="kolabnow.com">
|
||||
<incoming uri="imap+ssl+://imap.kolabnow.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.kolabnow.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="attorneymail.ch" label="KolabNow.com" domain="attorneymail.ch">
|
||||
<incoming uri="imap+ssl+://imap.kolabnow.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.kolabnow.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="barmail.ch" label="KolabNow.com" domain="barmail.ch">
|
||||
<incoming uri="imap+ssl+://imap.kolabnow.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.kolabnow.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="collaborative.li" label="KolabNow.com" domain="collaborative.li">
|
||||
<incoming uri="imap+ssl+://imap.kolabnow.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.kolabnow.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="diplomail.ch" label="KolabNow.com" domain="diplomail.ch">
|
||||
<incoming uri="imap+ssl+://imap.kolabnow.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.kolabnow.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="groupoffice.ch" label="KolabNow.com" domain="groupoffice.ch">
|
||||
<incoming uri="imap+ssl+://imap.kolabnow.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.kolabnow.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="journalistmail.ch" label="KolabNow.com" domain="journalistmail.ch">
|
||||
<incoming uri="imap+ssl+://imap.kolabnow.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.kolabnow.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="legalprivilege.ch" label="KolabNow.com" domain="legalprivilege.ch">
|
||||
<incoming uri="imap+ssl+://imap.kolabnow.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.kolabnow.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="libertymail.co" label="KolabNow.com" domain="libertymail.co">
|
||||
<incoming uri="imap+ssl+://imap.kolabnow.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.kolabnow.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="libertymail.net" label="KolabNow.com" domain="libertymail.net">
|
||||
<incoming uri="imap+ssl+://imap.kolabnow.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.kolabnow.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="mailatlaw.ch" label="KolabNow.com" domain="mailatlaw.ch">
|
||||
<incoming uri="imap+ssl+://imap.kolabnow.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.kolabnow.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="medmail.ch" label="KolabNow.com" domain="medmail.ch">
|
||||
<incoming uri="imap+ssl+://imap.kolabnow.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.kolabnow.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="mykolab.ch" label="KolabNow.com" domain="mykolab.ch">
|
||||
<incoming uri="imap+ssl+://imap.kolabnow.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.kolabnow.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="mykolab.com" label="KolabNow.com" domain="mykolab.com">
|
||||
<incoming uri="imap+ssl+://imap.kolabnow.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.kolabnow.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="myswissmail.ch" label="KolabNow.com" domain="myswissmail.ch">
|
||||
<incoming uri="imap+ssl+://imap.kolabnow.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.kolabnow.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="opengroupware.ch" label="KolabNow.com" domain="opengroupware.ch">
|
||||
<incoming uri="imap+ssl+://imap.kolabnow.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.kolabnow.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="pressmail.ch" label="KolabNow.com" domain="pressmail.ch">
|
||||
<incoming uri="imap+ssl+://imap.kolabnow.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.kolabnow.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="swisscollab.ch" label="KolabNow.com" domain="swisscollab.ch">
|
||||
<incoming uri="imap+ssl+://imap.kolabnow.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.kolabnow.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="swissgroupware.ch" label="KolabNow.com" domain="swissgroupware.ch">
|
||||
<incoming uri="imap+ssl+://imap.kolabnow.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.kolabnow.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="switzerlandmail.ch" label="KolabNow.com" domain="switzerlandmail.ch">
|
||||
<incoming uri="imap+ssl+://imap.kolabnow.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.kolabnow.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="trusted-legal-mail.ch" label="KolabNow.com" domain="trusted-legal-mail.ch">
|
||||
<incoming uri="imap+ssl+://imap.kolabnow.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.kolabnow.com" username="$email" />
|
||||
</provider>
|
||||
|
||||
<!-- Japanese -->
|
||||
<provider id="auone" label="au one" domain="auone.jp">
|
||||
<incoming uri="imap+ssl+://imap.gmail.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.gmail.com" username="$email" />
|
||||
</provider>
|
||||
|
||||
<!-- Korean -->
|
||||
<provider id="naver" label="Naver" domain="naver.com">
|
||||
<incoming uri="imap+ssl+://imap.naver.com" username="$user" />
|
||||
<outgoing uri="smtp+tls+://smtp.naver.com:587" username="$user" />
|
||||
</provider>
|
||||
<provider id="hanmail" label="Hanmail" domain="hanmail.net">
|
||||
<incoming uri="imap+ssl+://imap.hanmail.net" username="$user" />
|
||||
<outgoing uri="smtp+ssl+://smtp.hanmail.net" username="$user" />
|
||||
</provider>
|
||||
<provider id="daum" label="Hanmail" domain="daum.net">
|
||||
<incoming uri="imap+ssl+://imap.hanmail.net" username="$user" />
|
||||
<outgoing uri="smtp+ssl+://smtp.hanmail.net" username="$user" />
|
||||
</provider>
|
||||
|
||||
<!-- Russia -->
|
||||
<!-- Mail.Ru variants -->
|
||||
<provider id="rumailmailimap" label="mail.ru" domain="mail.ru">
|
||||
<incoming uri="imap+ssl+://imap.mail.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.ru" username="$email" />
|
||||
</provider>
|
||||
<provider id="rumailinboximap" label="inbox.ru" domain="inbox.ru">
|
||||
<incoming uri="imap+ssl+://imap.mail.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.ru" username="$email" />
|
||||
</provider>
|
||||
<provider id="rumaillistimap" label="list.ru" domain="list.ru">
|
||||
<incoming uri="imap+ssl+://imap.mail.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.ru" username="$email" />
|
||||
</provider>
|
||||
<provider id="rumailbkimap" label="bk.ru" domain="bk.ru">
|
||||
<incoming uri="imap+ssl+://imap.mail.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail.ru" username="$email" />
|
||||
</provider>
|
||||
<!-- Yandex variants -->
|
||||
<provider id="comyanyandeximap" label="yandex.com" domain="yandex.com">
|
||||
<incoming uri="imap+ssl+://imap.yandex.com" username="$user" />
|
||||
<outgoing uri="smtp+ssl+://smtp.yandex.com" username="$user" />
|
||||
</provider>
|
||||
<provider id="ruyanyandeximap" label="yandex.ru" domain="yandex.ru">
|
||||
<incoming uri="imap+ssl+://imap.yandex.ru" username="$user" />
|
||||
<outgoing uri="smtp+ssl+://smtp.yandex.ru" username="$user" />
|
||||
</provider>
|
||||
<provider id="ruyanyaimap" label="ya.ru" domain="ya.ru">
|
||||
<incoming uri="imap+ssl+://imap.ya.ru" username="$user" />
|
||||
<outgoing uri="smtp+ssl+://smtp.ya.ru" username="$user" />
|
||||
</provider>
|
||||
<provider id="byyandeximap" label="yandex.by" domain="yandex.by">
|
||||
<incoming uri="imap+ssl+://imap.yandex.by" username="$user" />
|
||||
<outgoing uri="smtp+ssl+://smtp.yandex.by" username="$user" />
|
||||
</provider>
|
||||
<provider id="kzyandeximap" label="yandex.kz" domain="yandex.kz">
|
||||
<incoming uri="imap+ssl+://imap.yandex.kz" username="$user" />
|
||||
<outgoing uri="smtp+ssl+://smtp.yandex.kz" username="$user" />
|
||||
</provider>
|
||||
<provider id="uayandeximap" label="yandex.ua" domain="yandex.ua">
|
||||
<incoming uri="imap+ssl+://imap.yandex.ua" username="$user" />
|
||||
<outgoing uri="smtp+ssl+://smtp.yandex.ua" username="$user" />
|
||||
</provider>
|
||||
<!-- Rambler.ru variants -->
|
||||
<provider id="ruramramblerimap" label="rambler.ru" domain="rambler.ru">
|
||||
<incoming uri="imap+ssl+://mail.rambler.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://mail.rambler.ru" username="$email" />
|
||||
</provider>
|
||||
<provider id="ruramlentaimap" label="lenta.ru" domain="lenta.ru">
|
||||
<incoming uri="imap+ssl+://mail.rambler.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://mail.rambler.ru" username="$email" />
|
||||
</provider>
|
||||
<provider id="ruramroimap" label="ro.ru" domain="ro.ru">
|
||||
<incoming uri="imap+ssl+://mail.rambler.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://mail.rambler.ru" username="$email" />
|
||||
</provider>
|
||||
<!-- QIP.RU variants -->
|
||||
<provider id="ruqipqipimap" label="qip.ru" domain="qip.ru">
|
||||
<incoming uri="imap+ssl+://imap.qip.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.qip.ru" username="$email" />
|
||||
</provider>
|
||||
<provider id="ruqippochtaimap" label="pochta.ru" domain="pochta.ru">
|
||||
<incoming uri="imap+ssl+://imap.pochta.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.pochta.ru" username="$email" />
|
||||
</provider>
|
||||
<provider id="comqipfromruimap" label="fromru.com" domain="fromru.com">
|
||||
<incoming uri="imap+ssl+://imap.fromru.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.fromru.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="ruqipfrontimap" label="front.ru" domain="front.ru">
|
||||
<incoming uri="imap+ssl+://imap.front.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.front.ru" username="$email" />
|
||||
</provider>
|
||||
<provider id="ruqiphotboximap" label="hotbox.ru" domain="hotbox.ru">
|
||||
<incoming uri="imap+ssl+://imap.hotbox.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.hotbox.ru" username="$email" />
|
||||
</provider>
|
||||
<provider id="ruqiphotmailimap" label="hotmail.ru" domain="hotmail.ru">
|
||||
<incoming uri="imap+ssl+://imap.hotmail.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.hotmail.ru" username="$email" />
|
||||
</provider>
|
||||
<provider id="suqipkrovatkaimap" label="krovatka.su" domain="krovatka.su">
|
||||
<incoming uri="imap+ssl+://imap.krovatka.su" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.krovatka.su" username="$email" />
|
||||
</provider>
|
||||
<provider id="ruqiplandimap" label="land.ru" domain="land.ru">
|
||||
<incoming uri="imap+ssl+://imap.land.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.land.ru" username="$email" />
|
||||
</provider>
|
||||
<provider id="comqipmail15imap" label="mail15.com" domain="mail15.com">
|
||||
<incoming uri="imap+ssl+://imap.mail15.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail15.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="comqipmail333imap" label="mail333.com" domain="mail333.com">
|
||||
<incoming uri="imap+ssl+://imap.mail333.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.mail333.com" username="$email" />
|
||||
</provider>
|
||||
<provider id="ruqipnewmailimap" label="newmail.ru" domain="newmail.ru">
|
||||
<incoming uri="imap+ssl+://imap.newmail.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.newmail.ru" username="$email" />
|
||||
</provider>
|
||||
<provider id="ruqipnightmailimap" label="nightmail.ru" domain="nightmail.ru">
|
||||
<incoming uri="imap+ssl+://imap.nightmail.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.nightmail.ru" username="$email" />
|
||||
</provider>
|
||||
<provider id="ruqipnmimap" label="nm.ru" domain="nm.ru">
|
||||
<incoming uri="imap+ssl+://imap.nm.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.nm.ru" username="$email" />
|
||||
</provider>
|
||||
<provider id="netqippisemimap" label="pisem.net" domain="pisem.net">
|
||||
<incoming uri="imap+ssl+://imap.pisem.net" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.pisem.net" username="$email" />
|
||||
</provider>
|
||||
<provider id="ruqippochtamtimap" label="pochtamt.ru" domain="pochtamt.ru">
|
||||
<incoming uri="imap+ssl+://imap.pochtamt.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.pochtamt.ru" username="$email" />
|
||||
</provider>
|
||||
<provider id="ruqippop3imap" label="pop3.ru" domain="pop3.ru">
|
||||
<incoming uri="imap+ssl+://imap.pop3.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.pop3.ru" username="$email" />
|
||||
</provider>
|
||||
<provider id="ruqiprbcmailimap" label="rbcmail.ru" domain="rbcmail.ru">
|
||||
<incoming uri="imap+ssl+://imap.rbcmail.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.rbcmail.ru" username="$email" />
|
||||
</provider>
|
||||
<provider id="ruqipsmtpimap" label="smtp.ru" domain="smtp.ru">
|
||||
<incoming uri="imap+ssl+://imap.smtp.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.smtp.ru" username="$email" />
|
||||
</provider>
|
||||
<provider id="ruqip5ballovimap" label="5ballov.ru" domain="5ballov.ru">
|
||||
<incoming uri="imap+ssl+://imap.5ballov.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.5ballov.ru" username="$email" />
|
||||
</provider>
|
||||
<provider id="ruqipaeternaimap" label="aeterna.ru" domain="aeterna.ru">
|
||||
<incoming uri="imap+ssl+://imap.aeterna.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.aeterna.ru" username="$email" />
|
||||
</provider>
|
||||
<provider id="ruqipzizaimap" label="ziza.ru" domain="ziza.ru">
|
||||
<incoming uri="imap+ssl+://imap.ziza.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.ziza.ru" username="$email" />
|
||||
</provider>
|
||||
<provider id="ruqipmemoriimap" label="memori.ru" domain="memori.ru">
|
||||
<incoming uri="imap+ssl+://imap.memori.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.memori.ru" username="$email" />
|
||||
</provider>
|
||||
<provider id="ruqipphotofileimap" label="photofile.ru" domain="photofile.ru">
|
||||
<incoming uri="imap+ssl+://imap.photofile.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.photofile.ru" username="$email" />
|
||||
</provider>
|
||||
<provider id="ruqipfotoplenkaimap" label="fotoplenka.ru" domain="fotoplenka.ru">
|
||||
<incoming uri="imap+ssl+://imap.fotoplenka.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.fotoplenka.ru" username="$email" />
|
||||
</provider>
|
||||
<provider id="comqippochtaimap" label="pochta.com" domain="pochta.com">
|
||||
<incoming uri="imap+ssl+://imap.pochta.ru" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.pochta.ru" username="$email" />
|
||||
</provider>
|
||||
|
||||
<!-- Slovakia -->
|
||||
<provider id="azet.sk" label="Azet.sk" domain="azet.sk">
|
||||
<incoming uri="imap+ssl+://imap.azet.sk" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.azet.sk" username="$email" />
|
||||
</provider>
|
||||
|
||||
<!-- The Netherlands -->
|
||||
<!-- Ziggo variants -->
|
||||
<provider id="casema.nl" label="Ziggo" domain="casema.nl">
|
||||
<incoming uri="imap+ssl+://imap.ziggo.nl" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.ziggo.nl" username="$email" />
|
||||
</provider>
|
||||
<provider id="chello.nl" label="Ziggo" domain="chello.nl">
|
||||
<incoming uri="imap+ssl+://imap.ziggo.nl" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.ziggo.nl" username="$email" />
|
||||
</provider>
|
||||
<provider id="hahah.nl" label="Ziggo" domain="hahah.nl">
|
||||
<incoming uri="imap+ssl+://imap.ziggo.nl" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.ziggo.nl" username="$email" />
|
||||
</provider>
|
||||
<provider id="home.nl" label="Ziggo" domain="home.nl">
|
||||
<incoming uri="imap+ssl+://imap.ziggo.nl" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.ziggo.nl" username="$email" />
|
||||
</provider>
|
||||
<provider id="multiweb.nl" label="Ziggo" domain="multiweb.nl">
|
||||
<incoming uri="imap+ssl+://imap.ziggo.nl" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.ziggo.nl" username="$email" />
|
||||
</provider>
|
||||
<provider id="quicknet.nl" label="Ziggo" domain="quicknet.nl">
|
||||
<incoming uri="imap+ssl+://imap.ziggo.nl" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.ziggo.nl" username="$email" />
|
||||
</provider>
|
||||
<provider id="razcall.com" label="Ziggo" domain="razcall.com">
|
||||
<incoming uri="imap+ssl+://imap.ziggo.nl" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.ziggo.nl" username="$email" />
|
||||
</provider>
|
||||
<provider id="razcall.nl" label="Ziggo" domain="razcall.nl">
|
||||
<incoming uri="imap+ssl+://imap.ziggo.nl" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.ziggo.nl" username="$email" />
|
||||
</provider>
|
||||
<provider id="upcmail.nl" label="Ziggo" domain="upcmail.nl">
|
||||
<incoming uri="imap+ssl+://imap.ziggo.nl" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.ziggo.nl" username="$email" />
|
||||
</provider>
|
||||
<provider id="zeggis.com" label="Ziggo" domain="zeggis.com">
|
||||
<incoming uri="imap+ssl+://imap.ziggo.nl" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.ziggo.nl" username="$email" />
|
||||
</provider>
|
||||
<provider id="zeggis.nl" label="Ziggo" domain="zeggis.nl">
|
||||
<incoming uri="imap+ssl+://imap.ziggo.nl" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.ziggo.nl" username="$email" />
|
||||
</provider>
|
||||
<provider id="ziggomail.com" label="Ziggo" domain="ziggomail.com">
|
||||
<incoming uri="imap+ssl+://imap.ziggo.nl" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.ziggo.nl" username="$email" />
|
||||
</provider>
|
||||
<provider id="ziggo.nl" label="Ziggo" domain="ziggo.nl">
|
||||
<incoming uri="imap+ssl+://imap.ziggo.nl" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.ziggo.nl" username="$email" />
|
||||
</provider>
|
||||
<provider id="zinders.nl" label="Ziggo" domain="zinders.nl">
|
||||
<incoming uri="imap+ssl+://imap.ziggo.nl" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.ziggo.nl" username="$email" />
|
||||
</provider>
|
||||
|
||||
<!-- EU wide -->
|
||||
<provider id="fairnatics.net" label="Fairnatics" domain="fairnatics.net">
|
||||
<incoming uri="imap+ssl+://mail.fairnatics.net" username="$email" />
|
||||
<outgoing uri="smtp+tls+://mail.fairnatics.net:25" username="$email" />
|
||||
</provider>
|
||||
|
||||
<!-- eFoundation -->
|
||||
<provider id="e.foundation" label="/e/" domain="e.email">
|
||||
<incoming uri="imap+ssl+://mail.ecloud.global" username="$email" />
|
||||
<outgoing uri="smtp+tls+://mail.ecloud.global" username="$email" />
|
||||
</provider>
|
||||
|
||||
<!-- AOL variants -->
|
||||
<provider domain="aol.com">
|
||||
<incoming uri="imap+ssl+://imap.aol.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.aol.com" username="$email" />
|
||||
</provider>
|
||||
<provider domain="aol.de">
|
||||
<incoming uri="imap+ssl+://imap.aol.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.aol.com" username="$email" />
|
||||
</provider>
|
||||
<provider domain="aol.it">
|
||||
<incoming uri="imap+ssl+://imap.aol.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.aol.com" username="$email" />
|
||||
</provider>
|
||||
<provider domain="aol.fr">
|
||||
<incoming uri="imap+ssl+://imap.aol.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.aol.com" username="$email" />
|
||||
</provider>
|
||||
<provider domain="aol.es">
|
||||
<incoming uri="imap+ssl+://imap.aol.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.aol.com" username="$email" />
|
||||
</provider>
|
||||
<provider domain="aol.se">
|
||||
<incoming uri="imap+ssl+://imap.aol.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.aol.com" username="$email" />
|
||||
</provider>
|
||||
<provider domain="aol.co.uk">
|
||||
<incoming uri="imap+ssl+://imap.aol.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.aol.com" username="$email" />
|
||||
</provider>
|
||||
<provider domain="aol.co.nz">
|
||||
<incoming uri="imap+ssl+://imap.aol.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.aol.com" username="$email" />
|
||||
</provider>
|
||||
<provider domain="aol.com.au">
|
||||
<incoming uri="imap+ssl+://imap.aol.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.aol.com" username="$email" />
|
||||
</provider>
|
||||
<provider domain="aol.com.ar">
|
||||
<incoming uri="imap+ssl+://imap.aol.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.aol.com" username="$email" />
|
||||
</provider>
|
||||
<provider domain="aol.com.br">
|
||||
<incoming uri="imap+ssl+://imap.aol.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.aol.com" username="$email" />
|
||||
</provider>
|
||||
<provider domain="aol.com.mx">
|
||||
<incoming uri="imap+ssl+://imap.aol.com" username="$email" />
|
||||
<outgoing uri="smtp+ssl+://smtp.aol.com" username="$email" />
|
||||
</provider>
|
||||
|
||||
<!-- Microsoft variants -->
|
||||
<provider domain="outlook.com">
|
||||
<incoming uri="imap+ssl+://outlook.office365.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.office365.com" username="$email" />
|
||||
</provider>
|
||||
<provider domain="hotmail.com">
|
||||
<incoming uri="imap+ssl+://outlook.office365.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.office365.com" username="$email" />
|
||||
</provider>
|
||||
<provider domain="msn.com">
|
||||
<incoming uri="imap+ssl+://outlook.office365.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.office365.com" username="$email" />
|
||||
</provider>
|
||||
<provider domain="live.com">
|
||||
<incoming uri="imap+ssl+://outlook.office365.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.office365.com" username="$email" />
|
||||
</provider>
|
||||
<provider domain="live.co.uk">
|
||||
<incoming uri="imap+ssl+://outlook.office365.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.office365.com" username="$email" />
|
||||
</provider>
|
||||
<provider domain="hotmail.co.uk">
|
||||
<incoming uri="imap+ssl+://outlook.office365.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.office365.com" username="$email" />
|
||||
</provider>
|
||||
<provider domain="outlook.sk">
|
||||
<incoming uri="imap+ssl+://outlook.office365.com" username="$email" />
|
||||
<outgoing uri="smtp+tls+://smtp.office365.com" username="$email" />
|
||||
</provider>
|
||||
|
||||
</providers>
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package com.fsck.k9.autodiscovery.providersxml
|
||||
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isNull
|
||||
import com.fsck.k9.RobolectricTest
|
||||
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 org.junit.Test
|
||||
|
||||
class ProvidersXmlDiscoveryTest : RobolectricTest() {
|
||||
private val xmlProvider = ProvidersXmlProvider(ApplicationProvider.getApplicationContext())
|
||||
private val oAuthConfigurationProvider = createOAuthConfigurationProvider()
|
||||
private val providersXmlDiscovery = ProvidersXmlDiscovery(xmlProvider, oAuthConfigurationProvider)
|
||||
|
||||
@Test
|
||||
fun discover_withGmailDomain_shouldReturnCorrectSettings() {
|
||||
val connectionSettings = providersXmlDiscovery.discover("user@gmail.com")
|
||||
|
||||
assertThat(connectionSettings).isNotNull()
|
||||
with(connectionSettings!!.incoming.first()) {
|
||||
assertThat(host).isEqualTo("imap.gmail.com")
|
||||
assertThat(security).isEqualTo(ConnectionSecurity.SSL_TLS_REQUIRED)
|
||||
assertThat(authType).isEqualTo(AuthType.XOAUTH2)
|
||||
assertThat(username).isEqualTo("user@gmail.com")
|
||||
}
|
||||
with(connectionSettings.outgoing.first()) {
|
||||
assertThat(host).isEqualTo("smtp.gmail.com")
|
||||
assertThat(security).isEqualTo(ConnectionSecurity.SSL_TLS_REQUIRED)
|
||||
assertThat(authType).isEqualTo(AuthType.XOAUTH2)
|
||||
assertThat(username).isEqualTo("user@gmail.com")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun discover_withUnknownDomain_shouldReturnNull() {
|
||||
val connectionSettings = providersXmlDiscovery.discover(
|
||||
"user@not.present.in.providers.xml.example"
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
11
app/autodiscovery/srvrecords/build.gradle.kts
Normal file
11
app/autodiscovery/srvrecords/build.gradle.kts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
@Suppress("DSL_SCOPE_VIOLATION")
|
||||
plugins {
|
||||
id(ThunderbirdPlugins.Library.jvm)
|
||||
alias(libs.plugins.android.lint)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.app.autodiscovery.api)
|
||||
|
||||
implementation(libs.minidns.hla)
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package com.fsck.k9.autodiscovery.srvrecords
|
||||
|
||||
import com.fsck.k9.mail.ConnectionSecurity.SSL_TLS_REQUIRED
|
||||
import com.fsck.k9.mail.ConnectionSecurity.STARTTLS_REQUIRED
|
||||
import org.minidns.dnslabel.DnsLabel
|
||||
import org.minidns.dnsname.DnsName
|
||||
import org.minidns.hla.ResolverApi
|
||||
import org.minidns.hla.srv.SrvProto
|
||||
|
||||
class MiniDnsSrvResolver : SrvResolver {
|
||||
override fun lookup(domain: String, type: SrvType): List<MailService> {
|
||||
val result = ResolverApi.INSTANCE.resolveSrv(
|
||||
DnsLabel.from(type.label),
|
||||
SrvProto.tcp.dnsLabel,
|
||||
DnsName.from(domain)
|
||||
)
|
||||
|
||||
val security = if (type.assumeTls) SSL_TLS_REQUIRED else STARTTLS_REQUIRED
|
||||
return result.answersOrEmptySet.map {
|
||||
MailService(
|
||||
srvType = type,
|
||||
host = it.target.toString(),
|
||||
port = it.port,
|
||||
priority = it.priority,
|
||||
security = security
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.fsck.k9.autodiscovery.srvrecords
|
||||
|
||||
interface SrvResolver {
|
||||
fun lookup(domain: String, type: SrvType): List<MailService>
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package com.fsck.k9.autodiscovery.srvrecords
|
||||
|
||||
import com.fsck.k9.autodiscovery.api.ConnectionSettingsDiscovery
|
||||
import com.fsck.k9.autodiscovery.api.DiscoveredServerSettings
|
||||
import com.fsck.k9.autodiscovery.api.DiscoveryResults
|
||||
import com.fsck.k9.helper.EmailHelper
|
||||
import com.fsck.k9.mail.AuthType
|
||||
import com.fsck.k9.mail.ConnectionSecurity
|
||||
|
||||
class SrvServiceDiscovery(
|
||||
private val srvResolver: MiniDnsSrvResolver
|
||||
) : ConnectionSettingsDiscovery {
|
||||
|
||||
override fun discover(email: String): DiscoveryResults? {
|
||||
val domain = EmailHelper.getDomainFromEmailAddress(email) ?: return null
|
||||
val mailServicePriority = compareBy<MailService> { it.priority }.thenByDescending { it.security }
|
||||
|
||||
val outgoingSettings = listOf(SrvType.SUBMISSIONS, SrvType.SUBMISSION)
|
||||
.flatMap { srvResolver.lookup(domain, it) }
|
||||
.sortedWith(mailServicePriority)
|
||||
.map { newServerSettings(it, email) }
|
||||
|
||||
val incomingSettings = listOf(SrvType.IMAPS, SrvType.IMAP)
|
||||
.flatMap { srvResolver.lookup(domain, it) }
|
||||
.sortedWith(mailServicePriority)
|
||||
.map { newServerSettings(it, email) }
|
||||
|
||||
return DiscoveryResults(incoming = incomingSettings, outgoing = outgoingSettings)
|
||||
}
|
||||
}
|
||||
|
||||
fun newServerSettings(service: MailService, email: String): DiscoveredServerSettings {
|
||||
return DiscoveredServerSettings(
|
||||
service.srvType.protocol,
|
||||
service.host,
|
||||
service.port,
|
||||
service.security,
|
||||
AuthType.PLAIN,
|
||||
email
|
||||
)
|
||||
}
|
||||
|
||||
enum class SrvType(val label: String, val protocol: String, val assumeTls: Boolean) {
|
||||
SUBMISSIONS("_submissions", "smtp", true),
|
||||
SUBMISSION("_submission", "smtp", false),
|
||||
IMAPS("_imaps", "imap", true),
|
||||
IMAP("_imap", "imap", false)
|
||||
}
|
||||
|
||||
data class MailService(
|
||||
val srvType: SrvType,
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val priority: Int,
|
||||
val security: ConnectionSecurity
|
||||
)
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
package com.fsck.k9.autodiscovery.srvrecords
|
||||
|
||||
import com.fsck.k9.autodiscovery.api.DiscoveryResults
|
||||
import com.fsck.k9.mail.ConnectionSecurity
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.mock
|
||||
|
||||
class SrvServiceDiscoveryTest {
|
||||
|
||||
@Test
|
||||
fun discover_whenNoMailServices_shouldReturnNoResults() {
|
||||
val srvResolver = newMockSrvResolver()
|
||||
|
||||
val srvServiceDiscovery = SrvServiceDiscovery(srvResolver)
|
||||
val result = srvServiceDiscovery.discover("test@example.com")
|
||||
|
||||
assertEquals(DiscoveryResults(listOf(), listOf()), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun discover_whenNoSMTP_shouldReturnJustIMAP() {
|
||||
val srvResolver = newMockSrvResolver(
|
||||
imapServices = listOf(newMailService(port = 143, srvType = SrvType.IMAP)),
|
||||
imapsServices = listOf(
|
||||
newMailService(port = 993, srvType = SrvType.IMAPS, security = ConnectionSecurity.SSL_TLS_REQUIRED)
|
||||
)
|
||||
)
|
||||
|
||||
val srvServiceDiscovery = SrvServiceDiscovery(srvResolver)
|
||||
val result = srvServiceDiscovery.discover("test@example.com")
|
||||
|
||||
assertEquals(2, result!!.incoming.size)
|
||||
assertEquals(0, result.outgoing.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun discover_whenNoIMAP_shouldReturnJustSMTP() {
|
||||
val srvResolver = newMockSrvResolver(
|
||||
submissionServices = listOf(
|
||||
newMailService(
|
||||
port = 25,
|
||||
srvType = SrvType.SUBMISSION,
|
||||
security = ConnectionSecurity.STARTTLS_REQUIRED
|
||||
),
|
||||
newMailService(
|
||||
port = 465,
|
||||
srvType = SrvType.SUBMISSIONS,
|
||||
security = ConnectionSecurity.SSL_TLS_REQUIRED
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val srvServiceDiscovery = SrvServiceDiscovery(srvResolver)
|
||||
val result = srvServiceDiscovery.discover("test@example.com")
|
||||
|
||||
assertEquals(0, result!!.incoming.size)
|
||||
assertEquals(2, result.outgoing.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun discover_withRequiredServices_shouldCorrectlyPrioritize() {
|
||||
val srvResolver = newMockSrvResolver(
|
||||
submissionServices = listOf(
|
||||
newMailService(
|
||||
host = "smtp1.example.com",
|
||||
port = 25,
|
||||
srvType = SrvType.SUBMISSION,
|
||||
security = ConnectionSecurity.STARTTLS_REQUIRED,
|
||||
priority = 0
|
||||
),
|
||||
newMailService(
|
||||
host = "smtp2.example.com",
|
||||
port = 25,
|
||||
srvType = SrvType.SUBMISSION,
|
||||
security = ConnectionSecurity.STARTTLS_REQUIRED,
|
||||
priority = 1
|
||||
)
|
||||
),
|
||||
submissionsServices = listOf(
|
||||
newMailService(
|
||||
host = "smtp3.example.com",
|
||||
port = 465,
|
||||
srvType = SrvType.SUBMISSIONS,
|
||||
security = ConnectionSecurity.SSL_TLS_REQUIRED,
|
||||
priority = 0
|
||||
),
|
||||
newMailService(
|
||||
host = "smtp4.example.com",
|
||||
port = 465,
|
||||
srvType = SrvType.SUBMISSIONS,
|
||||
security = ConnectionSecurity.SSL_TLS_REQUIRED,
|
||||
priority = 1
|
||||
)
|
||||
),
|
||||
imapServices = listOf(
|
||||
newMailService(
|
||||
host = "imap1.example.com",
|
||||
port = 143,
|
||||
srvType = SrvType.IMAP,
|
||||
security = ConnectionSecurity.STARTTLS_REQUIRED,
|
||||
priority = 0
|
||||
),
|
||||
newMailService(
|
||||
host = "imap2.example.com",
|
||||
port = 143,
|
||||
srvType = SrvType.IMAP,
|
||||
security = ConnectionSecurity.STARTTLS_REQUIRED,
|
||||
priority = 1
|
||||
)
|
||||
),
|
||||
imapsServices = listOf(
|
||||
newMailService(
|
||||
host = "imaps1.example.com",
|
||||
port = 993,
|
||||
srvType = SrvType.IMAPS,
|
||||
security = ConnectionSecurity.SSL_TLS_REQUIRED,
|
||||
priority = 0
|
||||
),
|
||||
newMailService(
|
||||
host = "imaps2.example.com",
|
||||
port = 993,
|
||||
srvType = SrvType.IMAPS,
|
||||
security = ConnectionSecurity.SSL_TLS_REQUIRED,
|
||||
priority = 1
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val srvServiceDiscovery = SrvServiceDiscovery(srvResolver)
|
||||
val result = srvServiceDiscovery.discover("test@example.com")
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"smtp3.example.com",
|
||||
"smtp1.example.com",
|
||||
"smtp4.example.com",
|
||||
"smtp2.example.com"
|
||||
),
|
||||
result?.outgoing?.map { it.host }
|
||||
)
|
||||
assertEquals(
|
||||
listOf(
|
||||
"imaps1.example.com",
|
||||
"imap1.example.com",
|
||||
"imaps2.example.com",
|
||||
"imap2.example.com"
|
||||
),
|
||||
result?.incoming?.map { it.host }
|
||||
)
|
||||
}
|
||||
|
||||
private fun newMailService(
|
||||
host: String = "example.com",
|
||||
priority: Int = 0,
|
||||
security: ConnectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED,
|
||||
srvType: SrvType,
|
||||
port: Int
|
||||
): MailService {
|
||||
return MailService(srvType, host, port, priority, security)
|
||||
}
|
||||
|
||||
private fun newMockSrvResolver(
|
||||
host: String = "example.com",
|
||||
submissionServices: List<MailService> = listOf(),
|
||||
submissionsServices: List<MailService> = listOf(),
|
||||
imapServices: List<MailService> = listOf(),
|
||||
imapsServices: List<MailService> = listOf()
|
||||
): MiniDnsSrvResolver {
|
||||
return mock {
|
||||
on { lookup(host, SrvType.SUBMISSION) } doReturn submissionServices
|
||||
on { lookup(host, SrvType.SUBMISSIONS) } doReturn submissionsServices
|
||||
on { lookup(host, SrvType.IMAP) } doReturn imapServices
|
||||
on { lookup(host, SrvType.IMAPS) } doReturn imapsServices
|
||||
}
|
||||
}
|
||||
}
|
||||
15
app/autodiscovery/thunderbird/build.gradle.kts
Normal file
15
app/autodiscovery/thunderbird/build.gradle.kts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
@Suppress("DSL_SCOPE_VIOLATION")
|
||||
plugins {
|
||||
id(ThunderbirdPlugins.Library.jvm)
|
||||
alias(libs.plugins.android.lint)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.app.autodiscovery.api)
|
||||
|
||||
compileOnly(libs.xmlpull)
|
||||
implementation(libs.okhttp)
|
||||
|
||||
testImplementation(libs.kxml2)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package com.fsck.k9.autodiscovery.thunderbird
|
||||
|
||||
import com.fsck.k9.logging.Timber
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
class ThunderbirdAutoconfigFetcher(private val okHttpClient: OkHttpClient) {
|
||||
|
||||
fun fetchAutoconfigFile(url: HttpUrl): InputStream? {
|
||||
return try {
|
||||
val request = Request.Builder().url(url).build()
|
||||
|
||||
val response = okHttpClient.newCall(request).execute()
|
||||
|
||||
if (response.isSuccessful) {
|
||||
response.body?.byteStream()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Timber.d(e, "Error fetching URL: %s", url)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
package com.fsck.k9.autodiscovery.thunderbird
|
||||
|
||||
import com.fsck.k9.autodiscovery.api.DiscoveredServerSettings
|
||||
import com.fsck.k9.autodiscovery.api.DiscoveryResults
|
||||
import com.fsck.k9.mail.AuthType
|
||||
import com.fsck.k9.mail.ConnectionSecurity
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.InputStreamReader
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
|
||||
/**
|
||||
* Parser for Thunderbird's
|
||||
* [Autoconfig file format](https://wiki.mozilla.org/Thunderbird:Autoconfiguration:ConfigFileFormat)
|
||||
*/
|
||||
class ThunderbirdAutoconfigParser {
|
||||
fun parseSettings(stream: InputStream, email: String): DiscoveryResults? {
|
||||
val factory = XmlPullParserFactory.newInstance()
|
||||
val xpp = factory.newPullParser()
|
||||
|
||||
xpp.setInput(InputStreamReader(stream))
|
||||
|
||||
val incomingServers = mutableListOf<DiscoveredServerSettings>()
|
||||
val outgoingServers = mutableListOf<DiscoveredServerSettings>()
|
||||
var eventType = xpp.eventType
|
||||
while (eventType != XmlPullParser.END_DOCUMENT) {
|
||||
if (eventType == XmlPullParser.START_TAG) {
|
||||
when (xpp.name) {
|
||||
"incomingServer" -> {
|
||||
incomingServers += parseServer(xpp, "incomingServer", email)
|
||||
}
|
||||
"outgoingServer" -> {
|
||||
outgoingServers += parseServer(xpp, "outgoingServer", email)
|
||||
}
|
||||
}
|
||||
}
|
||||
eventType = xpp.next()
|
||||
}
|
||||
return DiscoveryResults(incomingServers, outgoingServers)
|
||||
}
|
||||
|
||||
private fun parseServer(xpp: XmlPullParser, nodeName: String, email: String): DiscoveredServerSettings {
|
||||
val type = xpp.getAttributeValue(null, "type")
|
||||
var host: String? = null
|
||||
var username: String? = null
|
||||
var port: Int? = null
|
||||
var authType: AuthType? = null
|
||||
var connectionSecurity: ConnectionSecurity? = null
|
||||
|
||||
var eventType = xpp.eventType
|
||||
while (!(eventType == XmlPullParser.END_TAG && nodeName == xpp.name)) {
|
||||
if (eventType == XmlPullParser.START_TAG) {
|
||||
when (xpp.name) {
|
||||
"hostname" -> {
|
||||
host = getText(xpp)
|
||||
}
|
||||
"port" -> {
|
||||
port = getText(xpp).toInt()
|
||||
}
|
||||
"username" -> {
|
||||
username = getText(xpp).replace("%EMAILADDRESS%", email)
|
||||
}
|
||||
"authentication" -> {
|
||||
if (authType == null) authType = parseAuthType(getText(xpp))
|
||||
}
|
||||
"socketType" -> {
|
||||
connectionSecurity = parseSocketType(getText(xpp))
|
||||
}
|
||||
}
|
||||
}
|
||||
eventType = xpp.next()
|
||||
}
|
||||
|
||||
return DiscoveredServerSettings(type, host!!, port!!, connectionSecurity!!, authType, username)
|
||||
}
|
||||
|
||||
private fun parseAuthType(authentication: String): AuthType? {
|
||||
return when (authentication) {
|
||||
"password-cleartext" -> AuthType.PLAIN
|
||||
"TLS-client-cert" -> AuthType.EXTERNAL
|
||||
"secure" -> AuthType.CRAM_MD5
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSocketType(socketType: String): ConnectionSecurity? {
|
||||
return when (socketType) {
|
||||
"plain" -> ConnectionSecurity.NONE
|
||||
"SSL" -> ConnectionSecurity.SSL_TLS_REQUIRED
|
||||
"STARTTLS" -> ConnectionSecurity.STARTTLS_REQUIRED
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(XmlPullParserException::class, IOException::class)
|
||||
private fun getText(xpp: XmlPullParser): String {
|
||||
val eventType = xpp.next()
|
||||
return if (eventType != XmlPullParser.TEXT) "" else xpp.text
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package com.fsck.k9.autodiscovery.thunderbird
|
||||
|
||||
import com.fsck.k9.helper.EmailHelper
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
|
||||
class ThunderbirdAutoconfigUrlProvider {
|
||||
fun getAutoconfigUrls(email: String): List<HttpUrl> {
|
||||
val domain = EmailHelper.getDomainFromEmailAddress(email)
|
||||
requireNotNull(domain) { "Couldn't extract domain from email address: $email" }
|
||||
|
||||
return listOf(
|
||||
createProviderUrl(domain, email),
|
||||
createDomainUrl(scheme = "https", domain),
|
||||
createDomainUrl(scheme = "http", domain),
|
||||
createIspDbUrl(domain)
|
||||
)
|
||||
}
|
||||
|
||||
private fun createProviderUrl(domain: String?, email: String): HttpUrl {
|
||||
// https://autoconfig.{domain}/mail/config-v1.1.xml?emailaddress={email}
|
||||
return HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("autoconfig.$domain")
|
||||
.addEncodedPathSegments("mail/config-v1.1.xml")
|
||||
.addQueryParameter("emailaddress", email)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createDomainUrl(scheme: String, domain: String): HttpUrl {
|
||||
// https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml
|
||||
// http://{domain}/.well-known/autoconfig/mail/config-v1.1.xml
|
||||
return HttpUrl.Builder()
|
||||
.scheme(scheme)
|
||||
.host(domain)
|
||||
.addEncodedPathSegments(".well-known/autoconfig/mail/config-v1.1.xml")
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createIspDbUrl(domain: String): HttpUrl {
|
||||
// https://autoconfig.thunderbird.net/v1.1/{domain}
|
||||
return "https://autoconfig.thunderbird.net/v1.1/".toHttpUrl()
|
||||
.newBuilder()
|
||||
.addPathSegment(domain)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package com.fsck.k9.autodiscovery.thunderbird
|
||||
|
||||
import com.fsck.k9.autodiscovery.api.ConnectionSettingsDiscovery
|
||||
import com.fsck.k9.autodiscovery.api.DiscoveryResults
|
||||
|
||||
class ThunderbirdDiscovery(
|
||||
private val urlProvider: ThunderbirdAutoconfigUrlProvider,
|
||||
private val fetcher: ThunderbirdAutoconfigFetcher,
|
||||
private val parser: ThunderbirdAutoconfigParser
|
||||
) : ConnectionSettingsDiscovery {
|
||||
|
||||
override fun discover(email: String): DiscoveryResults? {
|
||||
val autoconfigUrls = urlProvider.getAutoconfigUrls(email)
|
||||
|
||||
return autoconfigUrls
|
||||
.asSequence()
|
||||
.mapNotNull { autoconfigUrl ->
|
||||
fetcher.fetchAutoconfigFile(autoconfigUrl)?.use { inputStream ->
|
||||
parser.parseSettings(inputStream, email)
|
||||
}
|
||||
}
|
||||
.firstOrNull { result ->
|
||||
result.incoming.isNotEmpty() || result.outgoing.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String = "Thunderbird autoconfig"
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package com.fsck.k9.autodiscovery.thunderbird
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNull
|
||||
import kotlin.test.assertNotNull
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.Test
|
||||
|
||||
class ThunderbirdAutoconfigFetcherTest {
|
||||
private val fetcher = ThunderbirdAutoconfigFetcher(OkHttpClient.Builder().build())
|
||||
|
||||
@Test
|
||||
fun shouldHandleNonexistentUrl() {
|
||||
val nonExistentUrl =
|
||||
"https://autoconfig.domain.invalid/mail/config-v1.1.xml?emailaddress=test%40domain.example".toHttpUrl()
|
||||
|
||||
val inputStream = fetcher.fetchAutoconfigFile(nonExistentUrl)
|
||||
|
||||
assertThat(inputStream).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldHandleEmptyResponse() {
|
||||
val server = MockWebServer().apply {
|
||||
this.enqueue(
|
||||
MockResponse()
|
||||
.setBody("")
|
||||
.setResponseCode(204),
|
||||
)
|
||||
start()
|
||||
}
|
||||
val url = server.url("/empty/")
|
||||
|
||||
val inputStream = fetcher.fetchAutoconfigFile(url)
|
||||
|
||||
assertNotNull(inputStream) { inputStream ->
|
||||
assertThat(inputStream.available()).isEqualTo(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
package com.fsck.k9.autodiscovery.thunderbird
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNotNull
|
||||
import com.fsck.k9.autodiscovery.api.DiscoveredServerSettings
|
||||
import com.fsck.k9.autodiscovery.api.DiscoveryResults
|
||||
import com.fsck.k9.mail.AuthType
|
||||
import com.fsck.k9.mail.ConnectionSecurity
|
||||
import org.junit.Test
|
||||
|
||||
class ThunderbirdAutoconfigTest {
|
||||
private val parser = ThunderbirdAutoconfigParser()
|
||||
|
||||
@Test
|
||||
fun settingsExtract() {
|
||||
val input =
|
||||
"""
|
||||
<?xml version="1.0"?>
|
||||
<clientConfig version="1.1">
|
||||
<emailProvider id="metacode.biz">
|
||||
<domain>metacode.biz</domain>
|
||||
|
||||
<incomingServer type="imap">
|
||||
<hostname>imap.googlemail.com</hostname>
|
||||
<port>993</port>
|
||||
<socketType>SSL</socketType>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<authentication>OAuth2</authentication>
|
||||
<authentication>password-cleartext</authentication>
|
||||
</incomingServer>
|
||||
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>smtp.googlemail.com</hostname>
|
||||
<port>465</port>
|
||||
<socketType>SSL</socketType>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<authentication>OAuth2</authentication>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<addThisServer>true</addThisServer>
|
||||
</outgoingServer>
|
||||
</emailProvider>
|
||||
</clientConfig>
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
val connectionSettings = parser.parseSettings(input, "test@metacode.biz")
|
||||
|
||||
assertThat(connectionSettings).isNotNull()
|
||||
assertThat(connectionSettings!!.incoming).isNotNull()
|
||||
assertThat(connectionSettings.outgoing).isNotNull()
|
||||
with(connectionSettings.incoming.first()) {
|
||||
assertThat(host).isEqualTo("imap.googlemail.com")
|
||||
assertThat(port).isEqualTo(993)
|
||||
assertThat(username).isEqualTo("test@metacode.biz")
|
||||
}
|
||||
with(connectionSettings.outgoing.first()) {
|
||||
assertThat(host).isEqualTo("smtp.googlemail.com")
|
||||
assertThat(port).isEqualTo(465)
|
||||
assertThat(username).isEqualTo("test@metacode.biz")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleServers() {
|
||||
val input =
|
||||
"""
|
||||
<?xml version="1.0"?>
|
||||
<clientConfig version="1.1">
|
||||
<emailProvider id="metacode.biz">
|
||||
<domain>metacode.biz</domain>
|
||||
|
||||
<incomingServer type="imap">
|
||||
<hostname>imap.googlemail.com</hostname>
|
||||
<port>993</port>
|
||||
<socketType>SSL</socketType>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<authentication>OAuth2</authentication>
|
||||
<authentication>password-cleartext</authentication>
|
||||
</incomingServer>
|
||||
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>first</hostname>
|
||||
<port>465</port>
|
||||
<socketType>SSL</socketType>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<authentication>OAuth2</authentication>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<addThisServer>true</addThisServer>
|
||||
</outgoingServer>
|
||||
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>second</hostname>
|
||||
<port>465</port>
|
||||
<socketType>SSL</socketType>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<authentication>OAuth2</authentication>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<addThisServer>true</addThisServer>
|
||||
</outgoingServer>
|
||||
</emailProvider>
|
||||
</clientConfig>
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
val discoveryResults = parser.parseSettings(input, "test@metacode.biz")
|
||||
|
||||
assertThat(discoveryResults).isNotNull()
|
||||
assertThat(discoveryResults!!.outgoing).isNotNull()
|
||||
with(discoveryResults.outgoing[0]) {
|
||||
assertThat(host).isEqualTo("first")
|
||||
assertThat(port).isEqualTo(465)
|
||||
assertThat(username).isEqualTo("test@metacode.biz")
|
||||
}
|
||||
with(discoveryResults.outgoing[1]) {
|
||||
assertThat(host).isEqualTo("second")
|
||||
assertThat(port).isEqualTo(465)
|
||||
assertThat(username).isEqualTo("test@metacode.biz")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidResponse() {
|
||||
val input =
|
||||
"""
|
||||
<?xml version="1.0"?>
|
||||
<clientConfig version="1.1">
|
||||
<emailProvider id="metacode.biz">
|
||||
<domain>metacode.biz</domain>
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
val connectionSettings = parser.parseSettings(input, "test@metacode.biz")
|
||||
|
||||
assertThat(connectionSettings).isEqualTo(DiscoveryResults(listOf(), listOf()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun incompleteConfiguration() {
|
||||
val input =
|
||||
"""
|
||||
<?xml version="1.0"?>
|
||||
<clientConfig version="1.1">
|
||||
<emailProvider id="metacode.biz">
|
||||
<domain>metacode.biz</domain>
|
||||
|
||||
<incomingServer type="imap">
|
||||
<hostname>imap.googlemail.com</hostname>
|
||||
<port>993</port>
|
||||
<socketType>SSL</socketType>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<authentication>OAuth2</authentication>
|
||||
<authentication>password-cleartext</authentication>
|
||||
</incomingServer>
|
||||
</emailProvider>
|
||||
</clientConfig>
|
||||
""".trimIndent().byteInputStream()
|
||||
|
||||
val connectionSettings = parser.parseSettings(input, "test@metacode.biz")
|
||||
|
||||
assertThat(connectionSettings).isEqualTo(
|
||||
DiscoveryResults(
|
||||
listOf(
|
||||
DiscoveredServerSettings(
|
||||
protocol = "imap",
|
||||
host = "imap.googlemail.com",
|
||||
port = 993,
|
||||
security = ConnectionSecurity.SSL_TLS_REQUIRED,
|
||||
authType = AuthType.PLAIN,
|
||||
username = "test@metacode.biz"
|
||||
)
|
||||
),
|
||||
listOf()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.fsck.k9.autodiscovery.thunderbird
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.containsExactly
|
||||
import org.junit.Test
|
||||
|
||||
class ThunderbirdAutoconfigUrlProviderTest {
|
||||
private val urlProvider = ThunderbirdAutoconfigUrlProvider()
|
||||
|
||||
@Test
|
||||
fun `getAutoconfigUrls with ASCII email address`() {
|
||||
val autoconfigUrls = urlProvider.getAutoconfigUrls("test@domain.example")
|
||||
|
||||
assertThat(autoconfigUrls.map { it.toString() }).containsExactly(
|
||||
"https://autoconfig.domain.example/mail/config-v1.1.xml?emailaddress=test%40domain.example",
|
||||
"https://domain.example/.well-known/autoconfig/mail/config-v1.1.xml",
|
||||
"http://domain.example/.well-known/autoconfig/mail/config-v1.1.xml",
|
||||
"https://autoconfig.thunderbird.net/v1.1/domain.example"
|
||||
)
|
||||
}
|
||||
}
|
||||
51
app/core/build.gradle.kts
Normal file
51
app/core/build.gradle.kts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
@Suppress("DSL_SCOPE_VIOLATION")
|
||||
plugins {
|
||||
id(ThunderbirdPlugins.Library.android)
|
||||
alias(libs.plugins.kotlin.parcelize)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.mail.common)
|
||||
api(projects.backend.api)
|
||||
api(projects.app.htmlCleaner)
|
||||
api(projects.core.android.common)
|
||||
|
||||
implementation(projects.plugins.openpgpApiLib.openpgpApi)
|
||||
|
||||
api(libs.koin.android)
|
||||
|
||||
api(libs.androidx.annotation)
|
||||
|
||||
implementation(libs.okio)
|
||||
implementation(libs.commons.io)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.work.ktx)
|
||||
implementation(libs.androidx.fragment)
|
||||
implementation(libs.androidx.localbroadcastmanager)
|
||||
implementation(libs.jsoup)
|
||||
implementation(libs.moshi)
|
||||
implementation(libs.timber)
|
||||
implementation(libs.mime4j.core)
|
||||
implementation(libs.mime4j.dom)
|
||||
|
||||
testApi(projects.core.testing)
|
||||
testImplementation(projects.mail.testing)
|
||||
testImplementation(projects.backend.imap)
|
||||
testImplementation(projects.mail.protocols.smtp)
|
||||
testImplementation(projects.app.storage)
|
||||
testImplementation(projects.app.testing)
|
||||
|
||||
testImplementation(libs.kotlin.test)
|
||||
testImplementation(libs.kotlin.reflect)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.androidx.test.core)
|
||||
testImplementation(libs.jdom2)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.fsck.k9.core"
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
7
app/core/src/main/AndroidManifest.xml
Normal file
7
app/core/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
|
||||
|
||||
</manifest>
|
||||
696
app/core/src/main/java/com/fsck/k9/Account.kt
Normal file
696
app/core/src/main/java/com/fsck/k9/Account.kt
Normal file
|
|
@ -0,0 +1,696 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import com.fsck.k9.backend.api.SyncConfig.ExpungePolicy
|
||||
import com.fsck.k9.mail.Address
|
||||
import com.fsck.k9.mail.ServerSettings
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Account stores all of the settings for a single account defined by the user. Each account is defined by a UUID.
|
||||
*/
|
||||
class Account(override val uuid: String) : BaseAccount {
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var deletePolicy = DeletePolicy.NEVER
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
private var internalIncomingServerSettings: ServerSettings? = null
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
private var internalOutgoingServerSettings: ServerSettings? = null
|
||||
|
||||
var incomingServerSettings: ServerSettings
|
||||
get() = internalIncomingServerSettings ?: error("Incoming server settings not set yet")
|
||||
set(value) {
|
||||
internalIncomingServerSettings = value
|
||||
}
|
||||
|
||||
var outgoingServerSettings: ServerSettings
|
||||
get() = internalOutgoingServerSettings ?: error("Outgoing server settings not set yet")
|
||||
set(value) {
|
||||
internalOutgoingServerSettings = value
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var oAuthState: String? = null
|
||||
|
||||
/**
|
||||
* Storage provider ID, used to locate and manage the underlying DB/file storage.
|
||||
*/
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var localStorageProviderId: String? = null
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
override var name: String? = null
|
||||
set(value) {
|
||||
field = value?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var alwaysBcc: String? = null
|
||||
|
||||
/**
|
||||
* -1 for never.
|
||||
*/
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var automaticCheckIntervalMinutes = 0
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var displayCount = 0
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value.takeIf { it != -1 } ?: K9.DEFAULT_VISIBLE_LIMIT
|
||||
isChangedVisibleLimits = true
|
||||
}
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var chipColor = 0
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var isNotifyNewMail = false
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var folderNotifyNewMailMode = FolderMode.ALL
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var isNotifySelfNewMail = false
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var isNotifyContactsMailOnly = false
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var isIgnoreChatMessages = false
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var legacyInboxFolder: String? = null
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var importedDraftsFolder: String? = null
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var importedSentFolder: String? = null
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var importedTrashFolder: String? = null
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var importedArchiveFolder: String? = null
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var importedSpamFolder: String? = null
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var inboxFolderId: Long? = null
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var outboxFolderId: Long? = null
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var draftsFolderId: Long? = null
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var sentFolderId: Long? = null
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var trashFolderId: Long? = null
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var archiveFolderId: Long? = null
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var spamFolderId: Long? = null
|
||||
|
||||
@get:Synchronized
|
||||
var draftsFolderSelection = SpecialFolderSelection.AUTOMATIC
|
||||
private set
|
||||
|
||||
@get:Synchronized
|
||||
var sentFolderSelection = SpecialFolderSelection.AUTOMATIC
|
||||
private set
|
||||
|
||||
@get:Synchronized
|
||||
var trashFolderSelection = SpecialFolderSelection.AUTOMATIC
|
||||
private set
|
||||
|
||||
@get:Synchronized
|
||||
var archiveFolderSelection = SpecialFolderSelection.AUTOMATIC
|
||||
private set
|
||||
|
||||
@get:Synchronized
|
||||
var spamFolderSelection = SpecialFolderSelection.AUTOMATIC
|
||||
private set
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var importedAutoExpandFolder: String? = null
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var autoExpandFolderId: Long? = null
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var folderDisplayMode = FolderMode.NOT_SECOND_CLASS
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var folderSyncMode = FolderMode.FIRST_CLASS
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var folderPushMode = FolderMode.NONE
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var folderTargetMode = FolderMode.NOT_SECOND_CLASS
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var accountNumber = 0
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var isNotifySync = false
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var sortType: SortType = SortType.SORT_DATE
|
||||
|
||||
private val sortAscending: MutableMap<SortType, Boolean> = mutableMapOf()
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var showPictures = ShowPictures.NEVER
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var isSignatureBeforeQuotedText = false
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var expungePolicy = Expunge.EXPUNGE_IMMEDIATELY
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var maxPushFolders = 0
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var idleRefreshMinutes = 0
|
||||
|
||||
@get:JvmName("useCompression")
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var useCompression = true
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var searchableFolders = Searchable.ALL
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var isSubscribedFoldersOnly = false
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var maximumPolledMessageAge = 0
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var maximumAutoDownloadMessageSize = 0
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var messageFormat = MessageFormat.HTML
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var isMessageFormatAuto = false
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var isMessageReadReceipt = false
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var quoteStyle = QuoteStyle.PREFIX
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var quotePrefix: String? = null
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var isDefaultQuotedTextShown = false
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var isReplyAfterQuote = false
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var isStripSignature = false
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var isSyncRemoteDeletions = false
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var openPgpProvider: String? = null
|
||||
set(value) {
|
||||
field = value?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var openPgpKey: Long = 0
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var autocryptPreferEncryptMutual = false
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var isOpenPgpHideSignOnly = false
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var isOpenPgpEncryptSubject = false
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var isOpenPgpEncryptAllDrafts = false
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var isMarkMessageAsReadOnView = false
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var isMarkMessageAsReadOnDelete = false
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var isAlwaysShowCcBcc = false
|
||||
|
||||
// Temporarily disabled
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var isRemoteSearchFullText = false
|
||||
get() = false
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var remoteSearchNumResults = 0
|
||||
set(value) {
|
||||
field = value.coerceAtLeast(0)
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var isUploadSentMessages = false
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var lastSyncTime: Long = 0
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var lastFolderListRefreshTime: Long = 0
|
||||
|
||||
@get:Synchronized
|
||||
var isFinishedSetup = false
|
||||
private set
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var messagesNotificationChannelVersion = 0
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var isChangedVisibleLimits = false
|
||||
private set
|
||||
|
||||
/**
|
||||
* Database ID of the folder that was last selected for a copy or move operation.
|
||||
*
|
||||
* Note: For now this value isn't persisted. So it will be reset when K-9 Mail is restarted.
|
||||
*/
|
||||
@get:Synchronized
|
||||
var lastSelectedFolderId: Long? = null
|
||||
private set
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var identities: MutableList<Identity> = mutableListOf()
|
||||
set(value) {
|
||||
field = value.toMutableList()
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
var notificationSettings = NotificationSettings()
|
||||
private set
|
||||
|
||||
val displayName: String
|
||||
get() = name ?: email
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
override var email: String
|
||||
get() = identities[0].email!!
|
||||
set(email) {
|
||||
val newIdentity = identities[0].withEmail(email)
|
||||
identities[0] = newIdentity
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var senderName: String?
|
||||
get() = identities[0].name
|
||||
set(name) {
|
||||
val newIdentity = identities[0].withName(name)
|
||||
identities[0] = newIdentity
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var signatureUse: Boolean
|
||||
get() = identities[0].signatureUse
|
||||
set(signatureUse) {
|
||||
val newIdentity = identities[0].withSignatureUse(signatureUse)
|
||||
identities[0] = newIdentity
|
||||
}
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var signature: String?
|
||||
get() = identities[0].signature
|
||||
set(signature) {
|
||||
val newIdentity = identities[0].withSignature(signature)
|
||||
identities[0] = newIdentity
|
||||
}
|
||||
|
||||
@get:JvmName("shouldMigrateToOAuth")
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var shouldMigrateToOAuth = false
|
||||
|
||||
/**
|
||||
* @param automaticCheckIntervalMinutes or -1 for never.
|
||||
*/
|
||||
@Synchronized
|
||||
fun updateAutomaticCheckIntervalMinutes(automaticCheckIntervalMinutes: Int): Boolean {
|
||||
val oldInterval = this.automaticCheckIntervalMinutes
|
||||
this.automaticCheckIntervalMinutes = automaticCheckIntervalMinutes
|
||||
|
||||
return oldInterval != automaticCheckIntervalMinutes
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setDraftsFolderId(folderId: Long?, selection: SpecialFolderSelection) {
|
||||
draftsFolderId = folderId
|
||||
draftsFolderSelection = selection
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun hasDraftsFolder(): Boolean {
|
||||
return draftsFolderId != null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setSentFolderId(folderId: Long?, selection: SpecialFolderSelection) {
|
||||
sentFolderId = folderId
|
||||
sentFolderSelection = selection
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun hasSentFolder(): Boolean {
|
||||
return sentFolderId != null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setTrashFolderId(folderId: Long?, selection: SpecialFolderSelection) {
|
||||
trashFolderId = folderId
|
||||
trashFolderSelection = selection
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun hasTrashFolder(): Boolean {
|
||||
return trashFolderId != null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setArchiveFolderId(folderId: Long?, selection: SpecialFolderSelection) {
|
||||
archiveFolderId = folderId
|
||||
archiveFolderSelection = selection
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun hasArchiveFolder(): Boolean {
|
||||
return archiveFolderId != null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setSpamFolderId(folderId: Long?, selection: SpecialFolderSelection) {
|
||||
spamFolderId = folderId
|
||||
spamFolderSelection = selection
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun hasSpamFolder(): Boolean {
|
||||
return spamFolderId != null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun updateFolderSyncMode(syncMode: FolderMode): Boolean {
|
||||
val oldSyncMode = folderSyncMode
|
||||
folderSyncMode = syncMode
|
||||
|
||||
return (oldSyncMode == FolderMode.NONE && syncMode != FolderMode.NONE) ||
|
||||
(oldSyncMode != FolderMode.NONE && syncMode == FolderMode.NONE)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun isSortAscending(sortType: SortType): Boolean {
|
||||
return sortAscending.getOrPut(sortType) { sortType.isDefaultAscending }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setSortAscending(sortType: SortType, sortAscending: Boolean) {
|
||||
this.sortAscending[sortType] = sortAscending
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun replaceIdentities(identities: List<Identity>) {
|
||||
this.identities = identities.toMutableList()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getIdentity(index: Int): Identity {
|
||||
if (index !in identities.indices) error("Identity with index $index not found")
|
||||
|
||||
return identities[index]
|
||||
}
|
||||
|
||||
fun isAnIdentity(addresses: Array<Address>?): Boolean {
|
||||
if (addresses == null) return false
|
||||
|
||||
return addresses.any { address -> isAnIdentity(address) }
|
||||
}
|
||||
|
||||
fun isAnIdentity(address: Address): Boolean {
|
||||
return findIdentity(address) != null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun findIdentity(address: Address): Identity? {
|
||||
return identities.find { identity ->
|
||||
identity.email.equals(address.address, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
val earliestPollDate: Date?
|
||||
get() {
|
||||
val age = maximumPolledMessageAge.takeIf { it >= 0 } ?: return null
|
||||
|
||||
val now = Calendar.getInstance()
|
||||
now[Calendar.HOUR_OF_DAY] = 0
|
||||
now[Calendar.MINUTE] = 0
|
||||
now[Calendar.SECOND] = 0
|
||||
now[Calendar.MILLISECOND] = 0
|
||||
|
||||
if (age < 28) {
|
||||
now.add(Calendar.DATE, age * -1)
|
||||
} else {
|
||||
when (age) {
|
||||
28 -> now.add(Calendar.MONTH, -1)
|
||||
56 -> now.add(Calendar.MONTH, -2)
|
||||
84 -> now.add(Calendar.MONTH, -3)
|
||||
168 -> now.add(Calendar.MONTH, -6)
|
||||
365 -> now.add(Calendar.YEAR, -1)
|
||||
}
|
||||
}
|
||||
|
||||
return now.time
|
||||
}
|
||||
|
||||
val isOpenPgpProviderConfigured: Boolean
|
||||
get() = openPgpProvider != null
|
||||
|
||||
@Synchronized
|
||||
fun hasOpenPgpKey(): Boolean {
|
||||
return openPgpKey != NO_OPENPGP_KEY
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setLastSelectedFolderId(folderId: Long) {
|
||||
lastSelectedFolderId = folderId
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun resetChangeMarkers() {
|
||||
isChangedVisibleLimits = false
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun markSetupFinished() {
|
||||
isFinishedSetup = true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun updateNotificationSettings(block: (oldNotificationSettings: NotificationSettings) -> NotificationSettings) {
|
||||
notificationSettings = block(notificationSettings)
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return if (K9.isSensitiveDebugLoggingEnabled) displayName else uuid
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return if (other is Account) {
|
||||
other.uuid == uuid
|
||||
} else {
|
||||
super.equals(other)
|
||||
}
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return uuid.hashCode()
|
||||
}
|
||||
|
||||
enum class FolderMode {
|
||||
NONE,
|
||||
ALL,
|
||||
FIRST_CLASS,
|
||||
FIRST_AND_SECOND_CLASS,
|
||||
NOT_SECOND_CLASS
|
||||
}
|
||||
|
||||
enum class SpecialFolderSelection {
|
||||
AUTOMATIC,
|
||||
MANUAL
|
||||
}
|
||||
|
||||
enum class ShowPictures {
|
||||
NEVER,
|
||||
ALWAYS,
|
||||
ONLY_FROM_CONTACTS
|
||||
}
|
||||
|
||||
enum class Searchable {
|
||||
ALL,
|
||||
DISPLAYABLE,
|
||||
NONE
|
||||
}
|
||||
|
||||
enum class QuoteStyle {
|
||||
PREFIX,
|
||||
HEADER
|
||||
}
|
||||
|
||||
enum class MessageFormat {
|
||||
TEXT,
|
||||
HTML,
|
||||
AUTO
|
||||
}
|
||||
|
||||
enum class Expunge {
|
||||
EXPUNGE_IMMEDIATELY,
|
||||
EXPUNGE_MANUALLY,
|
||||
EXPUNGE_ON_POLL;
|
||||
|
||||
fun toBackendExpungePolicy(): ExpungePolicy = when (this) {
|
||||
EXPUNGE_IMMEDIATELY -> ExpungePolicy.IMMEDIATELY
|
||||
EXPUNGE_MANUALLY -> ExpungePolicy.MANUALLY
|
||||
EXPUNGE_ON_POLL -> ExpungePolicy.ON_POLL
|
||||
}
|
||||
}
|
||||
|
||||
enum class DeletePolicy(@JvmField val setting: Int) {
|
||||
NEVER(0),
|
||||
SEVEN_DAYS(1),
|
||||
ON_DELETE(2),
|
||||
MARK_AS_READ(3);
|
||||
|
||||
companion object {
|
||||
fun fromInt(initialSetting: Int): DeletePolicy {
|
||||
return values().find { it.setting == initialSetting } ?: error("DeletePolicy $initialSetting unknown")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class SortType(val isDefaultAscending: Boolean) {
|
||||
SORT_DATE(false),
|
||||
SORT_ARRIVAL(false),
|
||||
SORT_SUBJECT(true),
|
||||
SORT_SENDER(true),
|
||||
SORT_UNREAD(true),
|
||||
SORT_FLAGGED(true),
|
||||
SORT_ATTACHMENT(true);
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Fixed name of outbox - not actually displayed.
|
||||
*/
|
||||
const val OUTBOX_NAME = "Outbox"
|
||||
|
||||
@JvmField
|
||||
val DEFAULT_SORT_TYPE = SortType.SORT_DATE
|
||||
const val DEFAULT_SORT_ASCENDING = false
|
||||
const val NO_OPENPGP_KEY: Long = 0
|
||||
const val UNASSIGNED_ACCOUNT_NUMBER = -1
|
||||
const val INTERVAL_MINUTES_NEVER = -1
|
||||
const val DEFAULT_SYNC_INTERVAL = 60
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,648 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import com.fsck.k9.Account.Companion.DEFAULT_SORT_ASCENDING
|
||||
import com.fsck.k9.Account.Companion.DEFAULT_SORT_TYPE
|
||||
import com.fsck.k9.Account.Companion.DEFAULT_SYNC_INTERVAL
|
||||
import com.fsck.k9.Account.Companion.NO_OPENPGP_KEY
|
||||
import com.fsck.k9.Account.Companion.UNASSIGNED_ACCOUNT_NUMBER
|
||||
import com.fsck.k9.Account.DeletePolicy
|
||||
import com.fsck.k9.Account.Expunge
|
||||
import com.fsck.k9.Account.FolderMode
|
||||
import com.fsck.k9.Account.MessageFormat
|
||||
import com.fsck.k9.Account.QuoteStyle
|
||||
import com.fsck.k9.Account.Searchable
|
||||
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.mailstore.StorageManager
|
||||
import com.fsck.k9.preferences.Storage
|
||||
import com.fsck.k9.preferences.StorageEditor
|
||||
import timber.log.Timber
|
||||
|
||||
class AccountPreferenceSerializer(
|
||||
private val storageManager: StorageManager,
|
||||
private val resourceProvider: CoreResourceProvider,
|
||||
private val serverSettingsSerializer: ServerSettingsSerializer
|
||||
) {
|
||||
|
||||
@Synchronized
|
||||
fun loadAccount(account: Account, storage: Storage) {
|
||||
val accountUuid = account.uuid
|
||||
with(account) {
|
||||
incomingServerSettings = serverSettingsSerializer.deserialize(
|
||||
storage.getString("$accountUuid.$INCOMING_SERVER_SETTINGS_KEY", "")
|
||||
)
|
||||
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)
|
||||
automaticCheckIntervalMinutes = storage.getInt("$accountUuid.automaticCheckIntervalMinutes", DEFAULT_SYNC_INTERVAL)
|
||||
idleRefreshMinutes = storage.getInt("$accountUuid.idleRefreshMinutes", 24)
|
||||
displayCount = storage.getInt("$accountUuid.displayCount", K9.DEFAULT_VISIBLE_LIMIT)
|
||||
if (displayCount < 0) {
|
||||
displayCount = K9.DEFAULT_VISIBLE_LIMIT
|
||||
}
|
||||
isNotifyNewMail = storage.getBoolean("$accountUuid.notifyNewMail", false)
|
||||
|
||||
folderNotifyNewMailMode = getEnumStringPref<FolderMode>(storage, "$accountUuid.folderNotifyNewMailMode", FolderMode.ALL)
|
||||
isNotifySelfNewMail = storage.getBoolean("$accountUuid.notifySelfNewMail", true)
|
||||
isNotifyContactsMailOnly = storage.getBoolean("$accountUuid.notifyContactsMailOnly", false)
|
||||
isIgnoreChatMessages = storage.getBoolean("$accountUuid.ignoreChatMessages", false)
|
||||
isNotifySync = storage.getBoolean("$accountUuid.notifyMailCheck", false)
|
||||
messagesNotificationChannelVersion = storage.getInt("$accountUuid.messagesNotificationChannelVersion", 0)
|
||||
deletePolicy = DeletePolicy.fromInt(storage.getInt("$accountUuid.deletePolicy", DeletePolicy.NEVER.setting))
|
||||
legacyInboxFolder = storage.getString("$accountUuid.inboxFolderName", null)
|
||||
importedDraftsFolder = storage.getString("$accountUuid.draftsFolderName", null)
|
||||
importedSentFolder = storage.getString("$accountUuid.sentFolderName", null)
|
||||
importedTrashFolder = storage.getString("$accountUuid.trashFolderName", null)
|
||||
importedArchiveFolder = storage.getString("$accountUuid.archiveFolderName", null)
|
||||
importedSpamFolder = storage.getString("$accountUuid.spamFolderName", null)
|
||||
|
||||
inboxFolderId = storage.getString("$accountUuid.inboxFolderId", null)?.toLongOrNull()
|
||||
outboxFolderId = storage.getString("$accountUuid.outboxFolderId", null)?.toLongOrNull()
|
||||
|
||||
val draftsFolderId = storage.getString("$accountUuid.draftsFolderId", null)?.toLongOrNull()
|
||||
val draftsFolderSelection = getEnumStringPref<SpecialFolderSelection>(
|
||||
storage,
|
||||
"$accountUuid.draftsFolderSelection",
|
||||
SpecialFolderSelection.AUTOMATIC
|
||||
)
|
||||
setDraftsFolderId(draftsFolderId, draftsFolderSelection)
|
||||
|
||||
val sentFolderId = storage.getString("$accountUuid.sentFolderId", null)?.toLongOrNull()
|
||||
val sentFolderSelection = getEnumStringPref<SpecialFolderSelection>(
|
||||
storage,
|
||||
"$accountUuid.sentFolderSelection",
|
||||
SpecialFolderSelection.AUTOMATIC
|
||||
)
|
||||
setSentFolderId(sentFolderId, sentFolderSelection)
|
||||
|
||||
val trashFolderId = storage.getString("$accountUuid.trashFolderId", null)?.toLongOrNull()
|
||||
val trashFolderSelection = getEnumStringPref<SpecialFolderSelection>(
|
||||
storage,
|
||||
"$accountUuid.trashFolderSelection",
|
||||
SpecialFolderSelection.AUTOMATIC
|
||||
)
|
||||
setTrashFolderId(trashFolderId, trashFolderSelection)
|
||||
|
||||
val archiveFolderId = storage.getString("$accountUuid.archiveFolderId", null)?.toLongOrNull()
|
||||
val archiveFolderSelection = getEnumStringPref<SpecialFolderSelection>(
|
||||
storage,
|
||||
"$accountUuid.archiveFolderSelection",
|
||||
SpecialFolderSelection.AUTOMATIC
|
||||
)
|
||||
setArchiveFolderId(archiveFolderId, archiveFolderSelection)
|
||||
|
||||
val spamFolderId = storage.getString("$accountUuid.spamFolderId", null)?.toLongOrNull()
|
||||
val spamFolderSelection = getEnumStringPref<SpecialFolderSelection>(
|
||||
storage,
|
||||
"$accountUuid.spamFolderSelection",
|
||||
SpecialFolderSelection.AUTOMATIC
|
||||
)
|
||||
setSpamFolderId(spamFolderId, spamFolderSelection)
|
||||
|
||||
autoExpandFolderId = storage.getString("$accountUuid.autoExpandFolderId", null)?.toLongOrNull()
|
||||
|
||||
expungePolicy = getEnumStringPref<Expunge>(storage, "$accountUuid.expungePolicy", Expunge.EXPUNGE_IMMEDIATELY)
|
||||
isSyncRemoteDeletions = storage.getBoolean("$accountUuid.syncRemoteDeletions", true)
|
||||
|
||||
maxPushFolders = storage.getInt("$accountUuid.maxPushFolders", 10)
|
||||
isSubscribedFoldersOnly = storage.getBoolean("$accountUuid.subscribedFoldersOnly", false)
|
||||
maximumPolledMessageAge = storage.getInt("$accountUuid.maximumPolledMessageAge", -1)
|
||||
maximumAutoDownloadMessageSize = storage.getInt("$accountUuid.maximumAutoDownloadMessageSize", 32768)
|
||||
messageFormat = getEnumStringPref<MessageFormat>(storage, "$accountUuid.messageFormat", DEFAULT_MESSAGE_FORMAT)
|
||||
val messageFormatAuto = storage.getBoolean("$accountUuid.messageFormatAuto", DEFAULT_MESSAGE_FORMAT_AUTO)
|
||||
if (messageFormatAuto && messageFormat == MessageFormat.TEXT) {
|
||||
messageFormat = MessageFormat.AUTO
|
||||
}
|
||||
isMessageReadReceipt = storage.getBoolean("$accountUuid.messageReadReceipt", DEFAULT_MESSAGE_READ_RECEIPT)
|
||||
quoteStyle = getEnumStringPref<QuoteStyle>(storage, "$accountUuid.quoteStyle", DEFAULT_QUOTE_STYLE)
|
||||
quotePrefix = storage.getString("$accountUuid.quotePrefix", DEFAULT_QUOTE_PREFIX)
|
||||
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)
|
||||
useCompression = storage.getBoolean("$accountUuid.useCompression", true)
|
||||
|
||||
importedAutoExpandFolder = storage.getString("$accountUuid.autoExpandFolderName", null)
|
||||
|
||||
accountNumber = storage.getInt("$accountUuid.accountNumber", UNASSIGNED_ACCOUNT_NUMBER)
|
||||
|
||||
chipColor = storage.getInt("$accountUuid.chipColor", FALLBACK_ACCOUNT_COLOR)
|
||||
|
||||
sortType = getEnumStringPref<SortType>(storage, "$accountUuid.sortTypeEnum", SortType.SORT_DATE)
|
||||
|
||||
setSortAscending(sortType, storage.getBoolean("$accountUuid.sortAscending", false))
|
||||
|
||||
showPictures = getEnumStringPref<ShowPictures>(storage, "$accountUuid.showPicturesEnum", ShowPictures.NEVER)
|
||||
|
||||
updateNotificationSettings {
|
||||
NotificationSettings(
|
||||
isRingEnabled = storage.getBoolean("$accountUuid.ring", true),
|
||||
ringtone = storage.getString("$accountUuid.ringtone", DEFAULT_RINGTONE_URI),
|
||||
light = getEnumStringPref(storage, "$accountUuid.notificationLight", NotificationLight.Disabled),
|
||||
vibration = NotificationVibration(
|
||||
isEnabled = storage.getBoolean("$accountUuid.vibrate", false),
|
||||
pattern = VibratePattern.deserialize(storage.getInt("$accountUuid.vibratePattern", 0)),
|
||||
repeatCount = storage.getInt("$accountUuid.vibrateTimes", 5)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
folderDisplayMode = getEnumStringPref<FolderMode>(storage, "$accountUuid.folderDisplayMode", FolderMode.NOT_SECOND_CLASS)
|
||||
|
||||
folderSyncMode = getEnumStringPref<FolderMode>(storage, "$accountUuid.folderSyncMode", FolderMode.FIRST_CLASS)
|
||||
|
||||
folderPushMode = getEnumStringPref<FolderMode>(storage, "$accountUuid.folderPushMode", FolderMode.NONE)
|
||||
|
||||
folderTargetMode = getEnumStringPref<FolderMode>(storage, "$accountUuid.folderTargetMode", FolderMode.NOT_SECOND_CLASS)
|
||||
|
||||
searchableFolders = getEnumStringPref<Searchable>(storage, "$accountUuid.searchableFolders", Searchable.ALL)
|
||||
|
||||
isSignatureBeforeQuotedText = storage.getBoolean("$accountUuid.signatureBeforeQuotedText", false)
|
||||
replaceIdentities(loadIdentities(accountUuid, storage))
|
||||
|
||||
openPgpProvider = storage.getString("$accountUuid.openPgpProvider", "")
|
||||
openPgpKey = storage.getLong("$accountUuid.cryptoKey", NO_OPENPGP_KEY)
|
||||
isOpenPgpHideSignOnly = storage.getBoolean("$accountUuid.openPgpHideSignOnly", true)
|
||||
isOpenPgpEncryptSubject = storage.getBoolean("$accountUuid.openPgpEncryptSubject", true)
|
||||
isOpenPgpEncryptAllDrafts = storage.getBoolean("$accountUuid.openPgpEncryptAllDrafts", true)
|
||||
autocryptPreferEncryptMutual = storage.getBoolean("$accountUuid.autocryptMutualMode", false)
|
||||
isRemoteSearchFullText = storage.getBoolean("$accountUuid.remoteSearchFullText", false)
|
||||
remoteSearchNumResults = storage.getInt("$accountUuid.remoteSearchNumResults", DEFAULT_REMOTE_SEARCH_NUM_RESULTS)
|
||||
isUploadSentMessages = storage.getBoolean("$accountUuid.uploadSentMessages", true)
|
||||
|
||||
isMarkMessageAsReadOnView = storage.getBoolean("$accountUuid.markMessageAsReadOnView", true)
|
||||
isMarkMessageAsReadOnDelete = storage.getBoolean("$accountUuid.markMessageAsReadOnDelete", true)
|
||||
isAlwaysShowCcBcc = storage.getBoolean("$accountUuid.alwaysShowCcBcc", false)
|
||||
lastSyncTime = storage.getLong("$accountUuid.lastSyncTime", 0L)
|
||||
lastFolderListRefreshTime = storage.getLong("$accountUuid.lastFolderListRefreshTime", 0L)
|
||||
|
||||
shouldMigrateToOAuth = storage.getBoolean("$accountUuid.migrateToOAuth", false)
|
||||
|
||||
val isFinishedSetup = storage.getBoolean("$accountUuid.isFinishedSetup", true)
|
||||
if (isFinishedSetup) markSetupFinished()
|
||||
|
||||
resetChangeMarkers()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun loadIdentities(accountUuid: String, storage: Storage): List<Identity> {
|
||||
val newIdentities = ArrayList<Identity>()
|
||||
var ident = 0
|
||||
var gotOne: Boolean
|
||||
do {
|
||||
gotOne = false
|
||||
val name = storage.getString("$accountUuid.$IDENTITY_NAME_KEY.$ident", null)
|
||||
val email = storage.getString("$accountUuid.$IDENTITY_EMAIL_KEY.$ident", null)
|
||||
val signatureUse = storage.getBoolean("$accountUuid.signatureUse.$ident", false)
|
||||
val signature = storage.getString("$accountUuid.signature.$ident", null)
|
||||
val description = storage.getString("$accountUuid.$IDENTITY_DESCRIPTION_KEY.$ident", null)
|
||||
val replyTo = storage.getString("$accountUuid.replyTo.$ident", null)
|
||||
if (email != null) {
|
||||
val identity = Identity(
|
||||
name = name,
|
||||
email = email,
|
||||
signatureUse = signatureUse,
|
||||
signature = signature,
|
||||
description = description,
|
||||
replyTo = replyTo
|
||||
)
|
||||
newIdentities.add(identity)
|
||||
gotOne = true
|
||||
}
|
||||
ident++
|
||||
} while (gotOne)
|
||||
|
||||
if (newIdentities.isEmpty()) {
|
||||
val name = storage.getString("$accountUuid.name", null)
|
||||
val email = storage.getString("$accountUuid.email", null)
|
||||
val signatureUse = storage.getBoolean("$accountUuid.signatureUse", false)
|
||||
val signature = storage.getString("$accountUuid.signature", null)
|
||||
val identity = Identity(
|
||||
name = name,
|
||||
email = email,
|
||||
signatureUse = signatureUse,
|
||||
signature = signature,
|
||||
description = email
|
||||
)
|
||||
newIdentities.add(identity)
|
||||
}
|
||||
|
||||
return newIdentities
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun save(editor: StorageEditor, storage: Storage, account: Account) {
|
||||
val accountUuid = account.uuid
|
||||
|
||||
if (!storage.getString("accountUuids", "").contains(account.uuid)) {
|
||||
var accountUuids = storage.getString("accountUuids", "")
|
||||
accountUuids += (if (accountUuids.isNotEmpty()) "," else "") + account.uuid
|
||||
editor.putString("accountUuids", accountUuids)
|
||||
}
|
||||
|
||||
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)
|
||||
editor.putInt("$accountUuid.automaticCheckIntervalMinutes", automaticCheckIntervalMinutes)
|
||||
editor.putInt("$accountUuid.idleRefreshMinutes", idleRefreshMinutes)
|
||||
editor.putInt("$accountUuid.displayCount", displayCount)
|
||||
editor.putBoolean("$accountUuid.notifyNewMail", isNotifyNewMail)
|
||||
editor.putString("$accountUuid.folderNotifyNewMailMode", folderNotifyNewMailMode.name)
|
||||
editor.putBoolean("$accountUuid.notifySelfNewMail", isNotifySelfNewMail)
|
||||
editor.putBoolean("$accountUuid.notifyContactsMailOnly", isNotifyContactsMailOnly)
|
||||
editor.putBoolean("$accountUuid.ignoreChatMessages", isIgnoreChatMessages)
|
||||
editor.putBoolean("$accountUuid.notifyMailCheck", isNotifySync)
|
||||
editor.putInt("$accountUuid.messagesNotificationChannelVersion", messagesNotificationChannelVersion)
|
||||
editor.putInt("$accountUuid.deletePolicy", deletePolicy.setting)
|
||||
editor.putString("$accountUuid.inboxFolderName", legacyInboxFolder)
|
||||
editor.putString("$accountUuid.draftsFolderName", importedDraftsFolder)
|
||||
editor.putString("$accountUuid.sentFolderName", importedSentFolder)
|
||||
editor.putString("$accountUuid.trashFolderName", importedTrashFolder)
|
||||
editor.putString("$accountUuid.archiveFolderName", importedArchiveFolder)
|
||||
editor.putString("$accountUuid.spamFolderName", importedSpamFolder)
|
||||
editor.putString("$accountUuid.inboxFolderId", inboxFolderId?.toString())
|
||||
editor.putString("$accountUuid.outboxFolderId", outboxFolderId?.toString())
|
||||
editor.putString("$accountUuid.draftsFolderId", draftsFolderId?.toString())
|
||||
editor.putString("$accountUuid.sentFolderId", sentFolderId?.toString())
|
||||
editor.putString("$accountUuid.trashFolderId", trashFolderId?.toString())
|
||||
editor.putString("$accountUuid.archiveFolderId", archiveFolderId?.toString())
|
||||
editor.putString("$accountUuid.spamFolderId", spamFolderId?.toString())
|
||||
editor.putString("$accountUuid.archiveFolderSelection", archiveFolderSelection.name)
|
||||
editor.putString("$accountUuid.draftsFolderSelection", draftsFolderSelection.name)
|
||||
editor.putString("$accountUuid.sentFolderSelection", sentFolderSelection.name)
|
||||
editor.putString("$accountUuid.spamFolderSelection", spamFolderSelection.name)
|
||||
editor.putString("$accountUuid.trashFolderSelection", trashFolderSelection.name)
|
||||
editor.putString("$accountUuid.autoExpandFolderName", importedAutoExpandFolder)
|
||||
editor.putString("$accountUuid.autoExpandFolderId", autoExpandFolderId?.toString())
|
||||
editor.putInt("$accountUuid.accountNumber", accountNumber)
|
||||
editor.putString("$accountUuid.sortTypeEnum", sortType.name)
|
||||
editor.putBoolean("$accountUuid.sortAscending", isSortAscending(sortType))
|
||||
editor.putString("$accountUuid.showPicturesEnum", showPictures.name)
|
||||
editor.putString("$accountUuid.folderDisplayMode", folderDisplayMode.name)
|
||||
editor.putString("$accountUuid.folderSyncMode", folderSyncMode.name)
|
||||
editor.putString("$accountUuid.folderPushMode", folderPushMode.name)
|
||||
editor.putString("$accountUuid.folderTargetMode", folderTargetMode.name)
|
||||
editor.putBoolean("$accountUuid.signatureBeforeQuotedText", isSignatureBeforeQuotedText)
|
||||
editor.putString("$accountUuid.expungePolicy", expungePolicy.name)
|
||||
editor.putBoolean("$accountUuid.syncRemoteDeletions", isSyncRemoteDeletions)
|
||||
editor.putInt("$accountUuid.maxPushFolders", maxPushFolders)
|
||||
editor.putString("$accountUuid.searchableFolders", searchableFolders.name)
|
||||
editor.putInt("$accountUuid.chipColor", chipColor)
|
||||
editor.putBoolean("$accountUuid.subscribedFoldersOnly", isSubscribedFoldersOnly)
|
||||
editor.putInt("$accountUuid.maximumPolledMessageAge", maximumPolledMessageAge)
|
||||
editor.putInt("$accountUuid.maximumAutoDownloadMessageSize", maximumAutoDownloadMessageSize)
|
||||
val messageFormatAuto = if (MessageFormat.AUTO == messageFormat) {
|
||||
// saving MessageFormat.AUTO as is to the database will cause downgrades to crash on
|
||||
// startup, so we save as MessageFormat.TEXT instead with a separate flag for auto.
|
||||
editor.putString("$accountUuid.messageFormat", MessageFormat.TEXT.name)
|
||||
true
|
||||
} else {
|
||||
editor.putString("$accountUuid.messageFormat", messageFormat.name)
|
||||
false
|
||||
}
|
||||
editor.putBoolean("$accountUuid.messageFormatAuto", messageFormatAuto)
|
||||
editor.putBoolean("$accountUuid.messageReadReceipt", isMessageReadReceipt)
|
||||
editor.putString("$accountUuid.quoteStyle", quoteStyle.name)
|
||||
editor.putString("$accountUuid.quotePrefix", quotePrefix)
|
||||
editor.putBoolean("$accountUuid.defaultQuotedTextShown", isDefaultQuotedTextShown)
|
||||
editor.putBoolean("$accountUuid.replyAfterQuote", isReplyAfterQuote)
|
||||
editor.putBoolean("$accountUuid.stripSignature", isStripSignature)
|
||||
editor.putLong("$accountUuid.cryptoKey", openPgpKey)
|
||||
editor.putBoolean("$accountUuid.openPgpHideSignOnly", isOpenPgpHideSignOnly)
|
||||
editor.putBoolean("$accountUuid.openPgpEncryptSubject", isOpenPgpEncryptSubject)
|
||||
editor.putBoolean("$accountUuid.openPgpEncryptAllDrafts", isOpenPgpEncryptAllDrafts)
|
||||
editor.putString("$accountUuid.openPgpProvider", openPgpProvider)
|
||||
editor.putBoolean("$accountUuid.autocryptMutualMode", autocryptPreferEncryptMutual)
|
||||
editor.putBoolean("$accountUuid.remoteSearchFullText", isRemoteSearchFullText)
|
||||
editor.putInt("$accountUuid.remoteSearchNumResults", remoteSearchNumResults)
|
||||
editor.putBoolean("$accountUuid.uploadSentMessages", isUploadSentMessages)
|
||||
editor.putBoolean("$accountUuid.markMessageAsReadOnView", isMarkMessageAsReadOnView)
|
||||
editor.putBoolean("$accountUuid.markMessageAsReadOnDelete", isMarkMessageAsReadOnDelete)
|
||||
editor.putBoolean("$accountUuid.alwaysShowCcBcc", isAlwaysShowCcBcc)
|
||||
|
||||
editor.putBoolean("$accountUuid.vibrate", notificationSettings.vibration.isEnabled)
|
||||
editor.putInt("$accountUuid.vibratePattern", notificationSettings.vibration.pattern.serialize())
|
||||
editor.putInt("$accountUuid.vibrateTimes", notificationSettings.vibration.repeatCount)
|
||||
editor.putBoolean("$accountUuid.ring", notificationSettings.isRingEnabled)
|
||||
editor.putString("$accountUuid.ringtone", notificationSettings.ringtone)
|
||||
editor.putString("$accountUuid.notificationLight", notificationSettings.light.name)
|
||||
editor.putLong("$accountUuid.lastSyncTime", lastSyncTime)
|
||||
editor.putLong("$accountUuid.lastFolderListRefreshTime", lastFolderListRefreshTime)
|
||||
editor.putBoolean("$accountUuid.isFinishedSetup", isFinishedSetup)
|
||||
editor.putBoolean("$accountUuid.useCompression", useCompression)
|
||||
editor.putBoolean("$accountUuid.migrateToOAuth", shouldMigrateToOAuth)
|
||||
}
|
||||
|
||||
saveIdentities(account, storage, editor)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun delete(editor: StorageEditor, storage: Storage, account: Account) {
|
||||
val accountUuid = account.uuid
|
||||
|
||||
// Get the list of account UUIDs
|
||||
val uuids = storage.getString("accountUuids", "").split(",".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||
|
||||
// Create a list of all account UUIDs excluding this account
|
||||
val newUuids = ArrayList<String>(uuids.size)
|
||||
for (uuid in uuids) {
|
||||
if (uuid != accountUuid) {
|
||||
newUuids.add(uuid)
|
||||
}
|
||||
}
|
||||
|
||||
// Only change the 'accountUuids' value if this account's UUID was listed before
|
||||
if (newUuids.size < uuids.size) {
|
||||
val accountUuids = Utility.combine(newUuids.toTypedArray(), ',')
|
||||
editor.putString("accountUuids", accountUuids)
|
||||
}
|
||||
|
||||
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")
|
||||
editor.remove("$accountUuid.alwaysBcc")
|
||||
editor.remove("$accountUuid.automaticCheckIntervalMinutes")
|
||||
editor.remove("$accountUuid.idleRefreshMinutes")
|
||||
editor.remove("$accountUuid.lastAutomaticCheckTime")
|
||||
editor.remove("$accountUuid.notifyNewMail")
|
||||
editor.remove("$accountUuid.notifySelfNewMail")
|
||||
editor.remove("$accountUuid.ignoreChatMessages")
|
||||
editor.remove("$accountUuid.messagesNotificationChannelVersion")
|
||||
editor.remove("$accountUuid.deletePolicy")
|
||||
editor.remove("$accountUuid.draftsFolderName")
|
||||
editor.remove("$accountUuid.sentFolderName")
|
||||
editor.remove("$accountUuid.trashFolderName")
|
||||
editor.remove("$accountUuid.archiveFolderName")
|
||||
editor.remove("$accountUuid.spamFolderName")
|
||||
editor.remove("$accountUuid.archiveFolderSelection")
|
||||
editor.remove("$accountUuid.draftsFolderSelection")
|
||||
editor.remove("$accountUuid.sentFolderSelection")
|
||||
editor.remove("$accountUuid.spamFolderSelection")
|
||||
editor.remove("$accountUuid.trashFolderSelection")
|
||||
editor.remove("$accountUuid.autoExpandFolderName")
|
||||
editor.remove("$accountUuid.accountNumber")
|
||||
editor.remove("$accountUuid.vibrate")
|
||||
editor.remove("$accountUuid.vibratePattern")
|
||||
editor.remove("$accountUuid.vibrateTimes")
|
||||
editor.remove("$accountUuid.ring")
|
||||
editor.remove("$accountUuid.ringtone")
|
||||
editor.remove("$accountUuid.folderDisplayMode")
|
||||
editor.remove("$accountUuid.folderSyncMode")
|
||||
editor.remove("$accountUuid.folderPushMode")
|
||||
editor.remove("$accountUuid.folderTargetMode")
|
||||
editor.remove("$accountUuid.signatureBeforeQuotedText")
|
||||
editor.remove("$accountUuid.expungePolicy")
|
||||
editor.remove("$accountUuid.syncRemoteDeletions")
|
||||
editor.remove("$accountUuid.maxPushFolders")
|
||||
editor.remove("$accountUuid.searchableFolders")
|
||||
editor.remove("$accountUuid.chipColor")
|
||||
editor.remove("$accountUuid.notificationLight")
|
||||
editor.remove("$accountUuid.subscribedFoldersOnly")
|
||||
editor.remove("$accountUuid.maximumPolledMessageAge")
|
||||
editor.remove("$accountUuid.maximumAutoDownloadMessageSize")
|
||||
editor.remove("$accountUuid.messageFormatAuto")
|
||||
editor.remove("$accountUuid.quoteStyle")
|
||||
editor.remove("$accountUuid.quotePrefix")
|
||||
editor.remove("$accountUuid.sortTypeEnum")
|
||||
editor.remove("$accountUuid.sortAscending")
|
||||
editor.remove("$accountUuid.showPicturesEnum")
|
||||
editor.remove("$accountUuid.replyAfterQuote")
|
||||
editor.remove("$accountUuid.stripSignature")
|
||||
editor.remove("$accountUuid.cryptoApp") // this is no longer set, but cleans up legacy values
|
||||
editor.remove("$accountUuid.cryptoAutoSignature")
|
||||
editor.remove("$accountUuid.cryptoAutoEncrypt")
|
||||
editor.remove("$accountUuid.cryptoApp")
|
||||
editor.remove("$accountUuid.cryptoKey")
|
||||
editor.remove("$accountUuid.cryptoSupportSignOnly")
|
||||
editor.remove("$accountUuid.openPgpProvider")
|
||||
editor.remove("$accountUuid.openPgpHideSignOnly")
|
||||
editor.remove("$accountUuid.openPgpEncryptSubject")
|
||||
editor.remove("$accountUuid.openPgpEncryptAllDrafts")
|
||||
editor.remove("$accountUuid.autocryptMutualMode")
|
||||
editor.remove("$accountUuid.enabled")
|
||||
editor.remove("$accountUuid.markMessageAsReadOnView")
|
||||
editor.remove("$accountUuid.markMessageAsReadOnDelete")
|
||||
editor.remove("$accountUuid.alwaysShowCcBcc")
|
||||
editor.remove("$accountUuid.remoteSearchFullText")
|
||||
editor.remove("$accountUuid.remoteSearchNumResults")
|
||||
editor.remove("$accountUuid.uploadSentMessages")
|
||||
editor.remove("$accountUuid.defaultQuotedTextShown")
|
||||
editor.remove("$accountUuid.displayCount")
|
||||
editor.remove("$accountUuid.inboxFolderName")
|
||||
editor.remove("$accountUuid.localStorageProvider")
|
||||
editor.remove("$accountUuid.messageFormat")
|
||||
editor.remove("$accountUuid.messageReadReceipt")
|
||||
editor.remove("$accountUuid.notifyMailCheck")
|
||||
editor.remove("$accountUuid.inboxFolderId")
|
||||
editor.remove("$accountUuid.outboxFolderId")
|
||||
editor.remove("$accountUuid.draftsFolderId")
|
||||
editor.remove("$accountUuid.sentFolderId")
|
||||
editor.remove("$accountUuid.trashFolderId")
|
||||
editor.remove("$accountUuid.archiveFolderId")
|
||||
editor.remove("$accountUuid.spamFolderId")
|
||||
editor.remove("$accountUuid.autoExpandFolderId")
|
||||
editor.remove("$accountUuid.lastSyncTime")
|
||||
editor.remove("$accountUuid.lastFolderListRefreshTime")
|
||||
editor.remove("$accountUuid.isFinishedSetup")
|
||||
editor.remove("$accountUuid.useCompression")
|
||||
editor.remove("$accountUuid.migrateToOAuth")
|
||||
|
||||
deleteIdentities(account, storage, editor)
|
||||
// TODO: Remove preference settings that may exist for individual folders in the account.
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun saveIdentities(account: Account, storage: Storage, editor: StorageEditor) {
|
||||
deleteIdentities(account, storage, editor)
|
||||
var ident = 0
|
||||
|
||||
with(account) {
|
||||
for (identity in identities) {
|
||||
editor.putString("$uuid.$IDENTITY_NAME_KEY.$ident", identity.name)
|
||||
editor.putString("$uuid.$IDENTITY_EMAIL_KEY.$ident", identity.email)
|
||||
editor.putBoolean("$uuid.signatureUse.$ident", identity.signatureUse)
|
||||
editor.putString("$uuid.signature.$ident", identity.signature)
|
||||
editor.putString("$uuid.$IDENTITY_DESCRIPTION_KEY.$ident", identity.description)
|
||||
editor.putString("$uuid.replyTo.$ident", identity.replyTo)
|
||||
ident++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun deleteIdentities(account: Account, storage: Storage, editor: StorageEditor) {
|
||||
val accountUuid = account.uuid
|
||||
|
||||
var identityIndex = 0
|
||||
var gotOne: Boolean
|
||||
do {
|
||||
gotOne = false
|
||||
val email = storage.getString("$accountUuid.$IDENTITY_EMAIL_KEY.$identityIndex", null)
|
||||
if (email != null) {
|
||||
editor.remove("$accountUuid.$IDENTITY_NAME_KEY.$identityIndex")
|
||||
editor.remove("$accountUuid.$IDENTITY_EMAIL_KEY.$identityIndex")
|
||||
editor.remove("$accountUuid.signatureUse.$identityIndex")
|
||||
editor.remove("$accountUuid.signature.$identityIndex")
|
||||
editor.remove("$accountUuid.$IDENTITY_DESCRIPTION_KEY.$identityIndex")
|
||||
editor.remove("$accountUuid.replyTo.$identityIndex")
|
||||
gotOne = true
|
||||
}
|
||||
identityIndex++
|
||||
} while (gotOne)
|
||||
}
|
||||
|
||||
fun move(editor: StorageEditor, account: Account, storage: Storage, newPosition: Int) {
|
||||
val accountUuids = storage.getString("accountUuids", "").split(",").filter { it.isNotEmpty() }
|
||||
val oldPosition = accountUuids.indexOf(account.uuid)
|
||||
if (oldPosition == -1 || oldPosition == newPosition) return
|
||||
|
||||
val newAccountUuidsString = accountUuids.toMutableList()
|
||||
.apply {
|
||||
removeAt(oldPosition)
|
||||
add(newPosition, account.uuid)
|
||||
}
|
||||
.joinToString(separator = ",")
|
||||
|
||||
editor.putString("accountUuids", newAccountUuidsString)
|
||||
}
|
||||
|
||||
private fun <T : Enum<T>> getEnumStringPref(storage: Storage, key: String, defaultEnum: T): T {
|
||||
val stringPref = storage.getString(key, null)
|
||||
|
||||
return if (stringPref == null) {
|
||||
defaultEnum
|
||||
} else {
|
||||
try {
|
||||
java.lang.Enum.valueOf<T>(defaultEnum.declaringJavaClass, stringPref)
|
||||
} catch (ex: IllegalArgumentException) {
|
||||
Timber.w(
|
||||
ex,
|
||||
"Unable to convert preference key [%s] value [%s] to enum of type %s",
|
||||
key,
|
||||
stringPref,
|
||||
defaultEnum.declaringJavaClass
|
||||
)
|
||||
|
||||
defaultEnum
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadDefaults(account: Account) {
|
||||
with(account) {
|
||||
localStorageProviderId = storageManager.defaultProviderId
|
||||
automaticCheckIntervalMinutes = DEFAULT_SYNC_INTERVAL
|
||||
idleRefreshMinutes = 24
|
||||
displayCount = K9.DEFAULT_VISIBLE_LIMIT
|
||||
accountNumber = UNASSIGNED_ACCOUNT_NUMBER
|
||||
isNotifyNewMail = true
|
||||
folderNotifyNewMailMode = FolderMode.ALL
|
||||
isNotifySync = false
|
||||
isNotifySelfNewMail = true
|
||||
isNotifyContactsMailOnly = false
|
||||
isIgnoreChatMessages = false
|
||||
messagesNotificationChannelVersion = 0
|
||||
folderDisplayMode = FolderMode.NOT_SECOND_CLASS
|
||||
folderSyncMode = FolderMode.FIRST_CLASS
|
||||
folderPushMode = FolderMode.NONE
|
||||
folderTargetMode = FolderMode.NOT_SECOND_CLASS
|
||||
sortType = DEFAULT_SORT_TYPE
|
||||
setSortAscending(DEFAULT_SORT_TYPE, DEFAULT_SORT_ASCENDING)
|
||||
showPictures = ShowPictures.NEVER
|
||||
isSignatureBeforeQuotedText = false
|
||||
expungePolicy = Expunge.EXPUNGE_IMMEDIATELY
|
||||
importedAutoExpandFolder = null
|
||||
legacyInboxFolder = null
|
||||
maxPushFolders = 10
|
||||
isSubscribedFoldersOnly = false
|
||||
maximumPolledMessageAge = -1
|
||||
maximumAutoDownloadMessageSize = 32768
|
||||
messageFormat = DEFAULT_MESSAGE_FORMAT
|
||||
isMessageFormatAuto = DEFAULT_MESSAGE_FORMAT_AUTO
|
||||
isMessageReadReceipt = DEFAULT_MESSAGE_READ_RECEIPT
|
||||
quoteStyle = DEFAULT_QUOTE_STYLE
|
||||
quotePrefix = DEFAULT_QUOTE_PREFIX
|
||||
isDefaultQuotedTextShown = DEFAULT_QUOTED_TEXT_SHOWN
|
||||
isReplyAfterQuote = DEFAULT_REPLY_AFTER_QUOTE
|
||||
isStripSignature = DEFAULT_STRIP_SIGNATURE
|
||||
isSyncRemoteDeletions = true
|
||||
openPgpKey = NO_OPENPGP_KEY
|
||||
isRemoteSearchFullText = false
|
||||
remoteSearchNumResults = DEFAULT_REMOTE_SEARCH_NUM_RESULTS
|
||||
isUploadSentMessages = true
|
||||
isMarkMessageAsReadOnView = true
|
||||
isMarkMessageAsReadOnDelete = true
|
||||
isAlwaysShowCcBcc = false
|
||||
lastSyncTime = 0L
|
||||
lastFolderListRefreshTime = 0L
|
||||
|
||||
setArchiveFolderId(null, SpecialFolderSelection.AUTOMATIC)
|
||||
setDraftsFolderId(null, SpecialFolderSelection.AUTOMATIC)
|
||||
setSentFolderId(null, SpecialFolderSelection.AUTOMATIC)
|
||||
setSpamFolderId(null, SpecialFolderSelection.AUTOMATIC)
|
||||
setTrashFolderId(null, SpecialFolderSelection.AUTOMATIC)
|
||||
setArchiveFolderId(null, SpecialFolderSelection.AUTOMATIC)
|
||||
|
||||
searchableFolders = Searchable.ALL
|
||||
|
||||
identities = ArrayList<Identity>()
|
||||
|
||||
val identity = Identity(
|
||||
signatureUse = false,
|
||||
signature = resourceProvider.defaultSignature(),
|
||||
description = resourceProvider.defaultIdentityDescription()
|
||||
)
|
||||
identities.add(identity)
|
||||
|
||||
updateNotificationSettings {
|
||||
NotificationSettings(
|
||||
isRingEnabled = true,
|
||||
ringtone = DEFAULT_RINGTONE_URI,
|
||||
light = NotificationLight.Disabled,
|
||||
vibration = NotificationVibration.DEFAULT
|
||||
)
|
||||
}
|
||||
|
||||
resetChangeMarkers()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACCOUNT_DESCRIPTION_KEY = "description"
|
||||
const val INCOMING_SERVER_SETTINGS_KEY = "incomingServerSettings"
|
||||
const val OUTGOING_SERVER_SETTINGS_KEY = "outgoingServerSettings"
|
||||
|
||||
const val IDENTITY_NAME_KEY = "name"
|
||||
const val IDENTITY_EMAIL_KEY = "email"
|
||||
const val IDENTITY_DESCRIPTION_KEY = "description"
|
||||
|
||||
const val FALLBACK_ACCOUNT_COLOR = 0x0099CC
|
||||
|
||||
@JvmField
|
||||
val DEFAULT_MESSAGE_FORMAT = MessageFormat.HTML
|
||||
|
||||
@JvmField
|
||||
val DEFAULT_QUOTE_STYLE = QuoteStyle.PREFIX
|
||||
const val DEFAULT_MESSAGE_FORMAT_AUTO = false
|
||||
const val DEFAULT_MESSAGE_READ_RECEIPT = false
|
||||
const val DEFAULT_QUOTE_PREFIX = ">"
|
||||
const val DEFAULT_QUOTED_TEXT_SHOWN = true
|
||||
const val DEFAULT_REPLY_AFTER_QUOTE = false
|
||||
const val DEFAULT_STRIP_SIGNATURE = true
|
||||
const val DEFAULT_REMOTE_SEARCH_NUM_RESULTS = 25
|
||||
const val DEFAULT_RINGTONE_URI = "content://settings/system/notification_sound"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.fsck.k9
|
||||
|
||||
fun interface AccountRemovedListener {
|
||||
fun onAccountRemoved(account: Account)
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.fsck.k9;
|
||||
|
||||
|
||||
public interface AccountsChangeListener {
|
||||
void onAccountsChanged();
|
||||
}
|
||||
11
app/core/src/main/java/com/fsck/k9/ActivityExtensions.kt
Normal file
11
app/core/src/main/java/com/fsck/k9/ActivityExtensions.kt
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import android.app.Activity
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.StringRes
|
||||
|
||||
fun Activity.finishWithErrorToast(@StringRes errorRes: Int, vararg formatArgs: String) {
|
||||
val text = getString(errorRes, *formatArgs)
|
||||
Toast.makeText(this, text, Toast.LENGTH_LONG).show()
|
||||
finish()
|
||||
}
|
||||
5
app/core/src/main/java/com/fsck/k9/AppConfig.kt
Normal file
5
app/core/src/main/java/com/fsck/k9/AppConfig.kt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package com.fsck.k9
|
||||
|
||||
data class AppConfig(
|
||||
val componentsToDisable: List<Class<*>>
|
||||
)
|
||||
7
app/core/src/main/java/com/fsck/k9/BaseAccount.kt
Normal file
7
app/core/src/main/java/com/fsck/k9/BaseAccount.kt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package com.fsck.k9
|
||||
|
||||
interface BaseAccount {
|
||||
val uuid: String
|
||||
val name: String?
|
||||
val email: String
|
||||
}
|
||||
89
app/core/src/main/java/com/fsck/k9/Core.kt
Normal file
89
app/core/src/main/java/com/fsck/k9/Core.kt
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import com.fsck.k9.job.K9JobManager
|
||||
import com.fsck.k9.mail.internet.BinaryTempFileBody
|
||||
import com.fsck.k9.notification.NotificationController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.qualifier.named
|
||||
|
||||
object Core : EarlyInit {
|
||||
private val context: Context by inject()
|
||||
private val appConfig: AppConfig by inject()
|
||||
private val jobManager: K9JobManager by inject()
|
||||
private val appCoroutineScope: CoroutineScope by inject(named("AppCoroutineScope"))
|
||||
private val preferences: Preferences by inject()
|
||||
private val notificationController: NotificationController by inject()
|
||||
|
||||
/**
|
||||
* This needs to be called from [Application#onCreate][android.app.Application#onCreate] before calling through
|
||||
* to the super class's `onCreate` implementation and before initializing the dependency injection library.
|
||||
*/
|
||||
fun earlyInit() {
|
||||
if (K9.DEVELOPER_MODE) {
|
||||
enableStrictMode()
|
||||
}
|
||||
}
|
||||
|
||||
fun init(context: Context) {
|
||||
BinaryTempFileBody.setTempDirectory(context.cacheDir)
|
||||
|
||||
setServicesEnabled(context)
|
||||
|
||||
restoreNotifications()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called throughout the application when the number of accounts has changed. This method
|
||||
* enables or disables the Compose activity, the boot receiver and the service based on
|
||||
* whether any accounts are configured.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun setServicesEnabled(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
val acctLength = Preferences.getPreferences().accounts.size
|
||||
val enable = acctLength > 0
|
||||
|
||||
setServicesEnabled(appContext, enable)
|
||||
}
|
||||
|
||||
fun setServicesEnabled() {
|
||||
setServicesEnabled(context)
|
||||
}
|
||||
|
||||
private fun setServicesEnabled(context: Context, enabled: Boolean) {
|
||||
val pm = context.packageManager
|
||||
|
||||
for (clazz in appConfig.componentsToDisable) {
|
||||
val alreadyEnabled = pm.getComponentEnabledSetting(ComponentName(context, clazz)) ==
|
||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
|
||||
if (enabled != alreadyEnabled) {
|
||||
pm.setComponentEnabledSetting(
|
||||
ComponentName(context, clazz),
|
||||
if (enabled) {
|
||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
} else {
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||
},
|
||||
PackageManager.DONT_KILL_APP
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
jobManager.scheduleAllMailJobs()
|
||||
}
|
||||
}
|
||||
|
||||
private fun restoreNotifications() {
|
||||
appCoroutineScope.launch(Dispatchers.IO) {
|
||||
val accounts = preferences.accounts
|
||||
notificationController.restoreNewMailNotifications(accounts)
|
||||
}
|
||||
}
|
||||
}
|
||||
36
app/core/src/main/java/com/fsck/k9/CoreKoinModules.kt
Normal file
36
app/core/src/main/java/com/fsck/k9/CoreKoinModules.kt
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import com.fsck.k9.autocrypt.autocryptModule
|
||||
import com.fsck.k9.controller.controllerModule
|
||||
import com.fsck.k9.controller.push.controllerPushModule
|
||||
import com.fsck.k9.crypto.openPgpModule
|
||||
import com.fsck.k9.helper.helperModule
|
||||
import com.fsck.k9.job.jobModule
|
||||
import com.fsck.k9.logging.loggingModule
|
||||
import com.fsck.k9.mailstore.mailStoreModule
|
||||
import com.fsck.k9.message.extractors.extractorModule
|
||||
import com.fsck.k9.message.html.htmlModule
|
||||
import com.fsck.k9.message.quote.quoteModule
|
||||
import com.fsck.k9.network.connectivityModule
|
||||
import com.fsck.k9.notification.coreNotificationModule
|
||||
import com.fsck.k9.power.powerModule
|
||||
import com.fsck.k9.preferences.preferencesModule
|
||||
|
||||
val coreModules = listOf(
|
||||
mainModule,
|
||||
openPgpModule,
|
||||
autocryptModule,
|
||||
mailStoreModule,
|
||||
extractorModule,
|
||||
htmlModule,
|
||||
quoteModule,
|
||||
coreNotificationModule,
|
||||
controllerModule,
|
||||
controllerPushModule,
|
||||
jobModule,
|
||||
helperModule,
|
||||
preferencesModule,
|
||||
connectivityModule,
|
||||
powerModule,
|
||||
loggingModule
|
||||
)
|
||||
35
app/core/src/main/java/com/fsck/k9/CoreResourceProvider.kt
Normal file
35
app/core/src/main/java/com/fsck/k9/CoreResourceProvider.kt
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import com.fsck.k9.notification.PushNotificationState
|
||||
|
||||
interface CoreResourceProvider {
|
||||
fun defaultSignature(): String
|
||||
fun defaultIdentityDescription(): String
|
||||
|
||||
fun contactDisplayNamePrefix(): String
|
||||
fun contactUnknownSender(): String
|
||||
fun contactUnknownRecipient(): String
|
||||
|
||||
fun messageHeaderFrom(): String
|
||||
fun messageHeaderTo(): String
|
||||
fun messageHeaderCc(): String
|
||||
fun messageHeaderDate(): String
|
||||
fun messageHeaderSubject(): String
|
||||
fun messageHeaderSeparator(): String
|
||||
|
||||
fun noSubject(): String
|
||||
fun userAgent(): String
|
||||
|
||||
fun encryptedSubject(): String
|
||||
fun replyHeader(sender: String): String
|
||||
fun replyHeader(sender: String, sentDate: String): String
|
||||
|
||||
fun searchUnifiedInboxTitle(): String
|
||||
fun searchUnifiedInboxDetail(): String
|
||||
|
||||
fun outboxFolderName(): String
|
||||
|
||||
val iconPushNotification: Int
|
||||
fun pushNotificationText(notificationState: PushNotificationState): String
|
||||
fun pushNotificationInfoText(): String
|
||||
}
|
||||
44
app/core/src/main/java/com/fsck/k9/DI.kt
Normal file
44
app/core/src/main/java/com/fsck/k9/DI.kt
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import android.app.Application
|
||||
import com.fsck.k9.core.BuildConfig
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.android.ext.koin.androidLogger
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.core.parameter.ParametersDefinition
|
||||
import org.koin.core.qualifier.Qualifier
|
||||
import org.koin.java.KoinJavaComponent.getKoin
|
||||
import org.koin.java.KoinJavaComponent.get as koinGet
|
||||
|
||||
object DI {
|
||||
private const val DEBUG = false
|
||||
|
||||
@JvmStatic fun start(application: Application, modules: List<Module>) {
|
||||
startKoin {
|
||||
if (BuildConfig.DEBUG && DEBUG) {
|
||||
androidLogger()
|
||||
}
|
||||
|
||||
androidContext(application)
|
||||
modules(modules)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun <T : Any> get(clazz: Class<T>): T {
|
||||
return koinGet(clazz)
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> get(): T {
|
||||
return koinGet(T::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
interface EarlyInit
|
||||
|
||||
// Copied from ComponentCallbacks.inject()
|
||||
inline fun <reified T : Any> EarlyInit.inject(
|
||||
qualifier: Qualifier? = null,
|
||||
noinline parameters: ParametersDefinition? = null
|
||||
) = lazy { getKoin().get<T>(qualifier, parameters) }
|
||||
27
app/core/src/main/java/com/fsck/k9/EmailAddressValidator.kt
Normal file
27
app/core/src/main/java/com/fsck/k9/EmailAddressValidator.kt
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import java.util.regex.Pattern
|
||||
|
||||
class EmailAddressValidator {
|
||||
|
||||
fun isValidAddressOnly(text: CharSequence): Boolean = EMAIL_ADDRESS_PATTERN.matcher(text).matches()
|
||||
|
||||
companion object {
|
||||
|
||||
// https://www.rfc-editor.org/rfc/rfc2396.txt (3.2.2)
|
||||
// https://www.rfc-editor.org/rfc/rfc5321.txt (4.1.2)
|
||||
|
||||
private const val ALPHA = "[a-zA-Z]"
|
||||
private const val ALPHANUM = "[a-zA-Z0-9]"
|
||||
private const val ATEXT = "[0-9a-zA-Z!#$%&'*+\\-/=?^_`{|}~]"
|
||||
private const val QCONTENT = "([\\p{Graph}\\p{Blank}&&[^\"\\\\]]|\\\\[\\p{Graph}\\p{Blank}])"
|
||||
private const val TOP_LABEL = "(($ALPHA($ALPHANUM|\\-|_)*$ALPHANUM)|$ALPHA)"
|
||||
private const val DOMAIN_LABEL = "(($ALPHANUM($ALPHANUM|\\-|_)*$ALPHANUM)|$ALPHANUM)"
|
||||
private const val HOST_NAME = "((($DOMAIN_LABEL\\.)+$TOP_LABEL)|$DOMAIN_LABEL)"
|
||||
|
||||
private val EMAIL_ADDRESS_PATTERN = Pattern.compile(
|
||||
"^($ATEXT+(\\.$ATEXT+)*|\"$QCONTENT+\")" +
|
||||
"\\@$HOST_NAME"
|
||||
)
|
||||
}
|
||||
}
|
||||
197
app/core/src/main/java/com/fsck/k9/FontSizes.java
Normal file
197
app/core/src/main/java/com/fsck/k9/FontSizes.java
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
package com.fsck.k9;
|
||||
|
||||
import android.util.TypedValue;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.fsck.k9.preferences.Storage;
|
||||
import com.fsck.k9.preferences.StorageEditor;
|
||||
|
||||
|
||||
/**
|
||||
* Manage font size of the information displayed in the message list and in the message view.
|
||||
*/
|
||||
public class FontSizes {
|
||||
private static final String MESSAGE_LIST_SUBJECT = "fontSizeMessageListSubject";
|
||||
private static final String MESSAGE_LIST_SENDER = "fontSizeMessageListSender";
|
||||
private static final String MESSAGE_LIST_DATE = "fontSizeMessageListDate";
|
||||
private static final String MESSAGE_LIST_PREVIEW = "fontSizeMessageListPreview";
|
||||
private static final String MESSAGE_VIEW_ACCOUNT_NAME = "fontSizeMessageViewAccountName";
|
||||
private static final String MESSAGE_VIEW_SENDER = "fontSizeMessageViewSender";
|
||||
private static final String MESSAGE_VIEW_RECIPIENTS = "fontSizeMessageViewTo";
|
||||
private static final String MESSAGE_VIEW_SUBJECT = "fontSizeMessageViewSubject";
|
||||
private static final String MESSAGE_VIEW_DATE = "fontSizeMessageViewDate";
|
||||
private static final String MESSAGE_VIEW_CONTENT_PERCENT = "fontSizeMessageViewContentPercent";
|
||||
private static final String MESSAGE_COMPOSE_INPUT = "fontSizeMessageComposeInput";
|
||||
|
||||
public static final int FONT_DEFAULT = -1; // Don't force-reset the size of this setting
|
||||
public static final int FONT_10SP = 10;
|
||||
public static final int FONT_12SP = 12;
|
||||
public static final int SMALL = 14; // ?android:attr/textAppearanceSmall
|
||||
public static final int FONT_16SP = 16;
|
||||
public static final int MEDIUM = 18; // ?android:attr/textAppearanceMedium
|
||||
public static final int FONT_20SP = 20;
|
||||
public static final int LARGE = 22; // ?android:attr/textAppearanceLarge
|
||||
|
||||
|
||||
private int messageListSubject;
|
||||
private int messageListSender;
|
||||
private int messageListDate;
|
||||
private int messageListPreview;
|
||||
private int messageViewAccountName;
|
||||
private int messageViewSender;
|
||||
private int messageViewRecipients;
|
||||
private int messageViewSubject;
|
||||
private int messageViewDate;
|
||||
private int messageViewContentPercent;
|
||||
private int messageComposeInput;
|
||||
|
||||
|
||||
public FontSizes() {
|
||||
messageListSubject = FONT_DEFAULT;
|
||||
messageListSender = FONT_DEFAULT;
|
||||
messageListDate = FONT_DEFAULT;
|
||||
messageListPreview = FONT_DEFAULT;
|
||||
|
||||
messageViewAccountName = FONT_DEFAULT;
|
||||
messageViewSender = FONT_DEFAULT;
|
||||
messageViewRecipients = FONT_DEFAULT;
|
||||
messageViewSubject = FONT_DEFAULT;
|
||||
messageViewDate = FONT_DEFAULT;
|
||||
messageViewContentPercent = 100;
|
||||
|
||||
messageComposeInput = MEDIUM;
|
||||
}
|
||||
|
||||
public void save(StorageEditor editor) {
|
||||
editor.putInt(MESSAGE_LIST_SUBJECT, messageListSubject);
|
||||
editor.putInt(MESSAGE_LIST_SENDER, messageListSender);
|
||||
editor.putInt(MESSAGE_LIST_DATE, messageListDate);
|
||||
editor.putInt(MESSAGE_LIST_PREVIEW, messageListPreview);
|
||||
|
||||
editor.putInt(MESSAGE_VIEW_ACCOUNT_NAME, messageViewAccountName);
|
||||
editor.putInt(MESSAGE_VIEW_SENDER, messageViewSender);
|
||||
editor.putInt(MESSAGE_VIEW_RECIPIENTS, messageViewRecipients);
|
||||
editor.putInt(MESSAGE_VIEW_SUBJECT, messageViewSubject);
|
||||
editor.putInt(MESSAGE_VIEW_DATE, messageViewDate);
|
||||
editor.putInt(MESSAGE_VIEW_CONTENT_PERCENT, getMessageViewContentAsPercent());
|
||||
|
||||
editor.putInt(MESSAGE_COMPOSE_INPUT, messageComposeInput);
|
||||
}
|
||||
|
||||
public void load(Storage storage) {
|
||||
messageListSubject = storage.getInt(MESSAGE_LIST_SUBJECT, messageListSubject);
|
||||
messageListSender = storage.getInt(MESSAGE_LIST_SENDER, messageListSender);
|
||||
messageListDate = storage.getInt(MESSAGE_LIST_DATE, messageListDate);
|
||||
messageListPreview = storage.getInt(MESSAGE_LIST_PREVIEW, messageListPreview);
|
||||
|
||||
messageViewAccountName = storage.getInt(MESSAGE_VIEW_ACCOUNT_NAME, messageViewAccountName);
|
||||
messageViewSender = storage.getInt(MESSAGE_VIEW_SENDER, messageViewSender);
|
||||
messageViewRecipients = storage.getInt(MESSAGE_VIEW_RECIPIENTS, messageViewRecipients);
|
||||
messageViewSubject = storage.getInt(MESSAGE_VIEW_SUBJECT, messageViewSubject);
|
||||
messageViewDate = storage.getInt(MESSAGE_VIEW_DATE, messageViewDate);
|
||||
|
||||
loadMessageViewContentPercent(storage);
|
||||
|
||||
messageComposeInput = storage.getInt(MESSAGE_COMPOSE_INPUT, messageComposeInput);
|
||||
}
|
||||
|
||||
private void loadMessageViewContentPercent(Storage storage) {
|
||||
setMessageViewContentAsPercent(storage.getInt(MESSAGE_VIEW_CONTENT_PERCENT, 100));
|
||||
}
|
||||
|
||||
public int getMessageListSubject() {
|
||||
return messageListSubject;
|
||||
}
|
||||
|
||||
public void setMessageListSubject(int messageListSubject) {
|
||||
this.messageListSubject = messageListSubject;
|
||||
}
|
||||
|
||||
public int getMessageListSender() {
|
||||
return messageListSender;
|
||||
}
|
||||
|
||||
public void setMessageListSender(int messageListSender) {
|
||||
this.messageListSender = messageListSender;
|
||||
}
|
||||
|
||||
public int getMessageListDate() {
|
||||
return messageListDate;
|
||||
}
|
||||
|
||||
public void setMessageListDate(int messageListDate) {
|
||||
this.messageListDate = messageListDate;
|
||||
}
|
||||
|
||||
public int getMessageListPreview() {
|
||||
return messageListPreview;
|
||||
}
|
||||
|
||||
public void setMessageListPreview(int messageListPreview) {
|
||||
this.messageListPreview = messageListPreview;
|
||||
}
|
||||
|
||||
public int getMessageViewAccountName() {
|
||||
return messageViewAccountName;
|
||||
}
|
||||
|
||||
public void setMessageViewAccountName(int messageViewAccountName) {
|
||||
this.messageViewAccountName = messageViewAccountName;
|
||||
}
|
||||
|
||||
public int getMessageViewSender() {
|
||||
return messageViewSender;
|
||||
}
|
||||
|
||||
public void setMessageViewSender(int messageViewSender) {
|
||||
this.messageViewSender = messageViewSender;
|
||||
}
|
||||
|
||||
public int getMessageViewRecipients() {
|
||||
return messageViewRecipients;
|
||||
}
|
||||
|
||||
public void setMessageViewRecipients(int messageViewRecipients) {
|
||||
this.messageViewRecipients = messageViewRecipients;
|
||||
}
|
||||
|
||||
public int getMessageViewSubject() {
|
||||
return messageViewSubject;
|
||||
}
|
||||
|
||||
public void setMessageViewSubject(int messageViewSubject) {
|
||||
this.messageViewSubject = messageViewSubject;
|
||||
}
|
||||
|
||||
public int getMessageViewDate() {
|
||||
return messageViewDate;
|
||||
}
|
||||
|
||||
public void setMessageViewDate(int messageViewDate) {
|
||||
this.messageViewDate = messageViewDate;
|
||||
}
|
||||
|
||||
public int getMessageViewContentAsPercent() {
|
||||
return messageViewContentPercent;
|
||||
}
|
||||
|
||||
public void setMessageViewContentAsPercent(int size) {
|
||||
messageViewContentPercent = size;
|
||||
}
|
||||
|
||||
public int getMessageComposeInput() {
|
||||
return messageComposeInput;
|
||||
}
|
||||
|
||||
public void setMessageComposeInput(int messageComposeInput) {
|
||||
this.messageComposeInput = messageComposeInput;
|
||||
}
|
||||
|
||||
// This, arguably, should live somewhere in a view class, but since we call it from activities, fragments
|
||||
// and views, where isn't exactly clear.
|
||||
public void setViewTextSize(TextView v, int fontSize) {
|
||||
if (fontSize != FONT_DEFAULT) {
|
||||
v.setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
20
app/core/src/main/java/com/fsck/k9/Identity.kt
Normal file
20
app/core/src/main/java/com/fsck/k9/Identity.kt
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class Identity(
|
||||
val description: String? = null,
|
||||
val name: String? = null,
|
||||
val email: String? = null,
|
||||
val signature: String? = null,
|
||||
val signatureUse: Boolean = false,
|
||||
val replyTo: String? = null
|
||||
) : Parcelable {
|
||||
// TODO remove when callers are converted to Kotlin
|
||||
fun withName(name: String?) = copy(name = name)
|
||||
fun withSignature(signature: String?) = copy(signature = signature)
|
||||
fun withSignatureUse(signatureUse: Boolean) = copy(signatureUse = signatureUse)
|
||||
fun withEmail(email: String?) = copy(email = email)
|
||||
}
|
||||
535
app/core/src/main/java/com/fsck/k9/K9.kt
Normal file
535
app/core/src/main/java/com/fsck/k9/K9.kt
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.fsck.k9.Account.SortType
|
||||
import com.fsck.k9.core.BuildConfig
|
||||
import com.fsck.k9.mail.K9MailLib
|
||||
import com.fsck.k9.mailstore.LocalStore
|
||||
import com.fsck.k9.preferences.RealGeneralSettingsManager
|
||||
import com.fsck.k9.preferences.Storage
|
||||
import com.fsck.k9.preferences.StorageEditor
|
||||
import kotlinx.datetime.Clock
|
||||
import timber.log.Timber
|
||||
import timber.log.Timber.DebugTree
|
||||
|
||||
@Deprecated("Use GeneralSettingsManager and GeneralSettings instead")
|
||||
object K9 : EarlyInit {
|
||||
private val generalSettingsManager: RealGeneralSettingsManager by inject()
|
||||
|
||||
/**
|
||||
* If this is `true`, various development settings will be enabled.
|
||||
*/
|
||||
@JvmField
|
||||
val DEVELOPER_MODE = BuildConfig.DEBUG
|
||||
|
||||
/**
|
||||
* Name of the [SharedPreferences] file used to store the last known version of the
|
||||
* accounts' databases.
|
||||
*
|
||||
* See `UpgradeDatabases` for a detailed explanation of the database upgrade process.
|
||||
*/
|
||||
private const val DATABASE_VERSION_CACHE = "database_version_cache"
|
||||
|
||||
/**
|
||||
* Key used to store the last known database version of the accounts' databases.
|
||||
*
|
||||
* @see DATABASE_VERSION_CACHE
|
||||
*/
|
||||
private const val KEY_LAST_ACCOUNT_DATABASE_VERSION = "last_account_database_version"
|
||||
|
||||
/**
|
||||
* A reference to the [SharedPreferences] used for caching the last known database version.
|
||||
*
|
||||
* @see checkCachedDatabaseVersion
|
||||
* @see setDatabasesUpToDate
|
||||
*/
|
||||
private var databaseVersionCache: SharedPreferences? = null
|
||||
|
||||
/**
|
||||
* @see areDatabasesUpToDate
|
||||
*/
|
||||
private var databasesUpToDate = false
|
||||
|
||||
/**
|
||||
* Check if we already know whether all databases are using the current database schema.
|
||||
*
|
||||
* This method is only used for optimizations. If it returns `true` we can be certain that getting a [LocalStore]
|
||||
* instance won't trigger a schema upgrade.
|
||||
*
|
||||
* @return `true`, if we know that all databases are using the current database schema. `false`, otherwise.
|
||||
*/
|
||||
@Synchronized
|
||||
@JvmStatic
|
||||
fun areDatabasesUpToDate(): Boolean {
|
||||
return databasesUpToDate
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember that all account databases are using the most recent database schema.
|
||||
*
|
||||
* @param save
|
||||
* Whether or not to write the current database version to the
|
||||
* `SharedPreferences` [.DATABASE_VERSION_CACHE].
|
||||
*
|
||||
* @see .areDatabasesUpToDate
|
||||
*/
|
||||
@Synchronized
|
||||
@JvmStatic
|
||||
fun setDatabasesUpToDate(save: Boolean) {
|
||||
databasesUpToDate = true
|
||||
|
||||
if (save) {
|
||||
val editor = databaseVersionCache!!.edit()
|
||||
editor.putInt(KEY_LAST_ACCOUNT_DATABASE_VERSION, LocalStore.getDbVersion())
|
||||
editor.apply()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the last known database version of the accounts' databases from a `SharedPreference`.
|
||||
*
|
||||
* If the stored version matches [LocalStore.getDbVersion] we know that the databases are up to date.
|
||||
* Using `SharedPreferences` should be a lot faster than opening all SQLite databases to get the current database
|
||||
* version.
|
||||
*
|
||||
* See the class `UpgradeDatabases` for a detailed explanation of the database upgrade process.
|
||||
*
|
||||
* @see areDatabasesUpToDate
|
||||
*/
|
||||
private fun checkCachedDatabaseVersion(context: Context) {
|
||||
databaseVersionCache = context.getSharedPreferences(DATABASE_VERSION_CACHE, Context.MODE_PRIVATE)
|
||||
|
||||
val cachedVersion = databaseVersionCache!!.getInt(KEY_LAST_ACCOUNT_DATABASE_VERSION, 0)
|
||||
|
||||
if (cachedVersion >= LocalStore.getDbVersion()) {
|
||||
setDatabasesUpToDate(false)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
var isDebugLoggingEnabled: Boolean = DEVELOPER_MODE
|
||||
set(debug) {
|
||||
field = debug
|
||||
updateLoggingStatus()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
var isSensitiveDebugLoggingEnabled: Boolean = false
|
||||
|
||||
@JvmStatic
|
||||
var k9Language = ""
|
||||
|
||||
@JvmStatic
|
||||
val fontSizes = FontSizes()
|
||||
|
||||
@JvmStatic
|
||||
var backgroundOps = BACKGROUND_OPS.ALWAYS
|
||||
|
||||
@JvmStatic
|
||||
var isShowAnimations = true
|
||||
|
||||
@JvmStatic
|
||||
var isConfirmDelete = false
|
||||
|
||||
@JvmStatic
|
||||
var isConfirmDiscardMessage = true
|
||||
|
||||
@JvmStatic
|
||||
var isConfirmDeleteStarred = false
|
||||
|
||||
@JvmStatic
|
||||
var isConfirmSpam = false
|
||||
|
||||
@JvmStatic
|
||||
var isConfirmDeleteFromNotification = true
|
||||
|
||||
@JvmStatic
|
||||
var isConfirmMarkAllRead = true
|
||||
|
||||
@JvmStatic
|
||||
var notificationQuickDeleteBehaviour = NotificationQuickDelete.ALWAYS
|
||||
|
||||
@JvmStatic
|
||||
var lockScreenNotificationVisibility = LockScreenNotificationVisibility.MESSAGE_COUNT
|
||||
|
||||
@JvmStatic
|
||||
var messageListDensity: UiDensity = UiDensity.Default
|
||||
|
||||
@JvmStatic
|
||||
var isShowMessageListStars = true
|
||||
|
||||
@JvmStatic
|
||||
var messageListPreviewLines = 2
|
||||
|
||||
@JvmStatic
|
||||
var isShowCorrespondentNames = true
|
||||
|
||||
@JvmStatic
|
||||
var isMessageListSenderAboveSubject = false
|
||||
|
||||
@JvmStatic
|
||||
var isShowContactName = false
|
||||
|
||||
@JvmStatic
|
||||
var isChangeContactNameColor = false
|
||||
|
||||
@JvmStatic
|
||||
var contactNameColor = 0xFF1093F5.toInt()
|
||||
|
||||
@JvmStatic
|
||||
var isShowContactPicture = true
|
||||
|
||||
@JvmStatic
|
||||
var isUseMessageViewFixedWidthFont = false
|
||||
|
||||
@JvmStatic
|
||||
var isMessageViewReturnToList = false
|
||||
|
||||
@JvmStatic
|
||||
var isMessageViewShowNext = false
|
||||
|
||||
@JvmStatic
|
||||
var isUseVolumeKeysForNavigation = false
|
||||
|
||||
@JvmStatic
|
||||
var isShowUnifiedInbox = true
|
||||
|
||||
@JvmStatic
|
||||
var isShowStarredCount = false
|
||||
|
||||
@JvmStatic
|
||||
var isAutoFitWidth: Boolean = false
|
||||
|
||||
var isQuietTimeEnabled = false
|
||||
var isNotificationDuringQuietTimeEnabled = true
|
||||
var quietTimeStarts: String? = null
|
||||
var quietTimeEnds: String? = null
|
||||
|
||||
@JvmStatic
|
||||
var isHideUserAgent = false
|
||||
|
||||
@JvmStatic
|
||||
var isHideTimeZone = false
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
@JvmStatic
|
||||
var sortType: SortType = Account.DEFAULT_SORT_TYPE
|
||||
private val sortAscending = mutableMapOf<SortType, Boolean>()
|
||||
|
||||
@JvmStatic
|
||||
var isUseBackgroundAsUnreadIndicator = false
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
var isShowComposeButtonOnMessageList = true
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
@JvmStatic
|
||||
var isThreadedViewEnabled = true
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
@JvmStatic
|
||||
var splitViewMode = SplitViewMode.NEVER
|
||||
|
||||
var isColorizeMissingContactPictures = true
|
||||
|
||||
@JvmStatic
|
||||
var isMessageViewArchiveActionVisible = false
|
||||
|
||||
@JvmStatic
|
||||
var isMessageViewDeleteActionVisible = true
|
||||
|
||||
@JvmStatic
|
||||
var isMessageViewMoveActionVisible = false
|
||||
|
||||
@JvmStatic
|
||||
var isMessageViewCopyActionVisible = false
|
||||
|
||||
@JvmStatic
|
||||
var isMessageViewSpamActionVisible = false
|
||||
|
||||
@JvmStatic
|
||||
var pgpInlineDialogCounter: Int = 0
|
||||
|
||||
@JvmStatic
|
||||
var pgpSignOnlyDialogCounter: Int = 0
|
||||
|
||||
@JvmStatic
|
||||
var swipeRightAction: SwipeAction = SwipeAction.ToggleSelection
|
||||
|
||||
@JvmStatic
|
||||
var swipeLeftAction: SwipeAction = SwipeAction.ToggleRead
|
||||
|
||||
val isQuietTime: Boolean
|
||||
get() {
|
||||
if (!isQuietTimeEnabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
val clock = DI.get<Clock>()
|
||||
val quietTimeChecker = QuietTimeChecker(clock, quietTimeStarts, quietTimeEnds)
|
||||
return quietTimeChecker.isQuietTime
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@JvmStatic
|
||||
fun isSortAscending(sortType: SortType): Boolean {
|
||||
if (sortAscending[sortType] == null) {
|
||||
sortAscending[sortType] = sortType.isDefaultAscending
|
||||
}
|
||||
return sortAscending[sortType]!!
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@JvmStatic
|
||||
fun setSortAscending(sortType: SortType, sortAscending: Boolean) {
|
||||
K9.sortAscending[sortType] = sortAscending
|
||||
}
|
||||
|
||||
fun init(context: Context) {
|
||||
K9MailLib.setDebugStatus(object : K9MailLib.DebugStatus {
|
||||
override fun enabled(): Boolean = isDebugLoggingEnabled
|
||||
|
||||
override fun debugSensitive(): Boolean = isSensitiveDebugLoggingEnabled
|
||||
})
|
||||
com.fsck.k9.logging.Timber.logger = TimberLogger()
|
||||
|
||||
checkCachedDatabaseVersion(context)
|
||||
|
||||
loadPrefs(generalSettingsManager.storage)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun loadPrefs(storage: Storage) {
|
||||
isDebugLoggingEnabled = storage.getBoolean("enableDebugLogging", DEVELOPER_MODE)
|
||||
isSensitiveDebugLoggingEnabled = storage.getBoolean("enableSensitiveLogging", false)
|
||||
isShowAnimations = storage.getBoolean("animations", true)
|
||||
isUseVolumeKeysForNavigation = storage.getBoolean("useVolumeKeysForNavigation", false)
|
||||
isShowUnifiedInbox = storage.getBoolean("showUnifiedInbox", true)
|
||||
isShowStarredCount = storage.getBoolean("showStarredCount", false)
|
||||
isMessageListSenderAboveSubject = storage.getBoolean("messageListSenderAboveSubject", false)
|
||||
isShowMessageListStars = storage.getBoolean("messageListStars", true)
|
||||
messageListPreviewLines = storage.getInt("messageListPreviewLines", 2)
|
||||
|
||||
isAutoFitWidth = storage.getBoolean("autofitWidth", true)
|
||||
|
||||
isQuietTimeEnabled = storage.getBoolean("quietTimeEnabled", false)
|
||||
isNotificationDuringQuietTimeEnabled = storage.getBoolean("notificationDuringQuietTimeEnabled", true)
|
||||
quietTimeStarts = storage.getString("quietTimeStarts", "21:00")
|
||||
quietTimeEnds = storage.getString("quietTimeEnds", "7:00")
|
||||
|
||||
messageListDensity = storage.getEnum("messageListDensity", UiDensity.Default)
|
||||
isShowCorrespondentNames = storage.getBoolean("showCorrespondentNames", true)
|
||||
isShowContactName = storage.getBoolean("showContactName", false)
|
||||
isShowContactPicture = storage.getBoolean("showContactPicture", true)
|
||||
isChangeContactNameColor = storage.getBoolean("changeRegisteredNameColor", false)
|
||||
contactNameColor = storage.getInt("registeredNameColor", 0xFF1093F5.toInt())
|
||||
isUseMessageViewFixedWidthFont = storage.getBoolean("messageViewFixedWidthFont", false)
|
||||
isMessageViewReturnToList = storage.getBoolean("messageViewReturnToList", false)
|
||||
isMessageViewShowNext = storage.getBoolean("messageViewShowNext", false)
|
||||
isHideUserAgent = storage.getBoolean("hideUserAgent", false)
|
||||
isHideTimeZone = storage.getBoolean("hideTimeZone", false)
|
||||
|
||||
isConfirmDelete = storage.getBoolean("confirmDelete", false)
|
||||
isConfirmDiscardMessage = storage.getBoolean("confirmDiscardMessage", true)
|
||||
isConfirmDeleteStarred = storage.getBoolean("confirmDeleteStarred", false)
|
||||
isConfirmSpam = storage.getBoolean("confirmSpam", false)
|
||||
isConfirmDeleteFromNotification = storage.getBoolean("confirmDeleteFromNotification", true)
|
||||
isConfirmMarkAllRead = storage.getBoolean("confirmMarkAllRead", true)
|
||||
|
||||
sortType = storage.getEnum("sortTypeEnum", Account.DEFAULT_SORT_TYPE)
|
||||
|
||||
val sortAscendingSetting = storage.getBoolean("sortAscending", Account.DEFAULT_SORT_ASCENDING)
|
||||
sortAscending[sortType] = sortAscendingSetting
|
||||
|
||||
notificationQuickDeleteBehaviour = storage.getEnum("notificationQuickDelete", NotificationQuickDelete.ALWAYS)
|
||||
|
||||
lockScreenNotificationVisibility = storage.getEnum(
|
||||
"lockScreenNotificationVisibility",
|
||||
LockScreenNotificationVisibility.MESSAGE_COUNT
|
||||
)
|
||||
|
||||
splitViewMode = storage.getEnum("splitViewMode", SplitViewMode.NEVER)
|
||||
|
||||
isUseBackgroundAsUnreadIndicator = storage.getBoolean("useBackgroundAsUnreadIndicator", false)
|
||||
isShowComposeButtonOnMessageList = storage.getBoolean("showComposeButtonOnMessageList", true)
|
||||
isThreadedViewEnabled = storage.getBoolean("threadedView", true)
|
||||
fontSizes.load(storage)
|
||||
|
||||
backgroundOps = storage.getEnum("backgroundOperations", BACKGROUND_OPS.ALWAYS)
|
||||
|
||||
isColorizeMissingContactPictures = storage.getBoolean("colorizeMissingContactPictures", true)
|
||||
|
||||
isMessageViewArchiveActionVisible = storage.getBoolean("messageViewArchiveActionVisible", false)
|
||||
isMessageViewDeleteActionVisible = storage.getBoolean("messageViewDeleteActionVisible", true)
|
||||
isMessageViewMoveActionVisible = storage.getBoolean("messageViewMoveActionVisible", false)
|
||||
isMessageViewCopyActionVisible = storage.getBoolean("messageViewCopyActionVisible", false)
|
||||
isMessageViewSpamActionVisible = storage.getBoolean("messageViewSpamActionVisible", false)
|
||||
|
||||
pgpInlineDialogCounter = storage.getInt("pgpInlineDialogCounter", 0)
|
||||
pgpSignOnlyDialogCounter = storage.getInt("pgpSignOnlyDialogCounter", 0)
|
||||
|
||||
k9Language = storage.getString("language", "")
|
||||
|
||||
swipeRightAction = storage.getEnum("swipeRightAction", SwipeAction.ToggleSelection)
|
||||
swipeLeftAction = storage.getEnum("swipeLeftAction", SwipeAction.ToggleRead)
|
||||
}
|
||||
|
||||
internal fun save(editor: StorageEditor) {
|
||||
editor.putBoolean("enableDebugLogging", isDebugLoggingEnabled)
|
||||
editor.putBoolean("enableSensitiveLogging", isSensitiveDebugLoggingEnabled)
|
||||
editor.putEnum("backgroundOperations", backgroundOps)
|
||||
editor.putBoolean("animations", isShowAnimations)
|
||||
editor.putBoolean("useVolumeKeysForNavigation", isUseVolumeKeysForNavigation)
|
||||
editor.putBoolean("autofitWidth", isAutoFitWidth)
|
||||
editor.putBoolean("quietTimeEnabled", isQuietTimeEnabled)
|
||||
editor.putBoolean("notificationDuringQuietTimeEnabled", isNotificationDuringQuietTimeEnabled)
|
||||
editor.putString("quietTimeStarts", quietTimeStarts)
|
||||
editor.putString("quietTimeEnds", quietTimeEnds)
|
||||
|
||||
editor.putEnum("messageListDensity", messageListDensity)
|
||||
editor.putBoolean("messageListSenderAboveSubject", isMessageListSenderAboveSubject)
|
||||
editor.putBoolean("showUnifiedInbox", isShowUnifiedInbox)
|
||||
editor.putBoolean("showStarredCount", isShowStarredCount)
|
||||
editor.putBoolean("messageListStars", isShowMessageListStars)
|
||||
editor.putInt("messageListPreviewLines", messageListPreviewLines)
|
||||
editor.putBoolean("showCorrespondentNames", isShowCorrespondentNames)
|
||||
editor.putBoolean("showContactName", isShowContactName)
|
||||
editor.putBoolean("showContactPicture", isShowContactPicture)
|
||||
editor.putBoolean("changeRegisteredNameColor", isChangeContactNameColor)
|
||||
editor.putInt("registeredNameColor", contactNameColor)
|
||||
editor.putBoolean("messageViewFixedWidthFont", isUseMessageViewFixedWidthFont)
|
||||
editor.putBoolean("messageViewReturnToList", isMessageViewReturnToList)
|
||||
editor.putBoolean("messageViewShowNext", isMessageViewShowNext)
|
||||
editor.putBoolean("hideUserAgent", isHideUserAgent)
|
||||
editor.putBoolean("hideTimeZone", isHideTimeZone)
|
||||
|
||||
editor.putString("language", k9Language)
|
||||
|
||||
editor.putBoolean("confirmDelete", isConfirmDelete)
|
||||
editor.putBoolean("confirmDiscardMessage", isConfirmDiscardMessage)
|
||||
editor.putBoolean("confirmDeleteStarred", isConfirmDeleteStarred)
|
||||
editor.putBoolean("confirmSpam", isConfirmSpam)
|
||||
editor.putBoolean("confirmDeleteFromNotification", isConfirmDeleteFromNotification)
|
||||
editor.putBoolean("confirmMarkAllRead", isConfirmMarkAllRead)
|
||||
|
||||
editor.putEnum("sortTypeEnum", sortType)
|
||||
editor.putBoolean("sortAscending", sortAscending[sortType] ?: false)
|
||||
|
||||
editor.putString("notificationQuickDelete", notificationQuickDeleteBehaviour.toString())
|
||||
editor.putString("lockScreenNotificationVisibility", lockScreenNotificationVisibility.toString())
|
||||
|
||||
editor.putBoolean("useBackgroundAsUnreadIndicator", isUseBackgroundAsUnreadIndicator)
|
||||
editor.putBoolean("showComposeButtonOnMessageList", isShowComposeButtonOnMessageList)
|
||||
editor.putBoolean("threadedView", isThreadedViewEnabled)
|
||||
editor.putEnum("splitViewMode", splitViewMode)
|
||||
editor.putBoolean("colorizeMissingContactPictures", isColorizeMissingContactPictures)
|
||||
|
||||
editor.putBoolean("messageViewArchiveActionVisible", isMessageViewArchiveActionVisible)
|
||||
editor.putBoolean("messageViewDeleteActionVisible", isMessageViewDeleteActionVisible)
|
||||
editor.putBoolean("messageViewMoveActionVisible", isMessageViewMoveActionVisible)
|
||||
editor.putBoolean("messageViewCopyActionVisible", isMessageViewCopyActionVisible)
|
||||
editor.putBoolean("messageViewSpamActionVisible", isMessageViewSpamActionVisible)
|
||||
|
||||
editor.putInt("pgpInlineDialogCounter", pgpInlineDialogCounter)
|
||||
editor.putInt("pgpSignOnlyDialogCounter", pgpSignOnlyDialogCounter)
|
||||
|
||||
editor.putEnum("swipeRightAction", swipeRightAction)
|
||||
editor.putEnum("swipeLeftAction", swipeLeftAction)
|
||||
|
||||
fontSizes.save(editor)
|
||||
}
|
||||
|
||||
private fun updateLoggingStatus() {
|
||||
Timber.uprootAll()
|
||||
if (isDebugLoggingEnabled) {
|
||||
Timber.plant(DebugTree())
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun saveSettingsAsync() {
|
||||
generalSettingsManager.saveSettingsAsync()
|
||||
}
|
||||
|
||||
private inline fun <reified T : Enum<T>> Storage.getEnum(key: String, defaultValue: T): T {
|
||||
return try {
|
||||
val value = getString(key, null)
|
||||
if (value != null) {
|
||||
enumValueOf(value)
|
||||
} else {
|
||||
defaultValue
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e("Couldn't read setting '%s'. Using default value instead.", key)
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T : Enum<T>> StorageEditor.putEnum(key: String, value: T) {
|
||||
putString(key, value.name)
|
||||
}
|
||||
|
||||
const val LOCAL_UID_PREFIX = "K9LOCAL:"
|
||||
|
||||
const val IDENTITY_HEADER = K9MailLib.IDENTITY_HEADER
|
||||
|
||||
/**
|
||||
* Specifies how many messages will be shown in a folder by default. This number is set
|
||||
* on each new folder and can be incremented with "Load more messages..." by the
|
||||
* VISIBLE_LIMIT_INCREMENT
|
||||
*/
|
||||
const val DEFAULT_VISIBLE_LIMIT = 25
|
||||
|
||||
/**
|
||||
* The maximum size of an attachment we're willing to download (either View or Save)
|
||||
* Attachments that are base64 encoded (most) will be about 1.375x their actual size
|
||||
* so we should probably factor that in. A 5MB attachment will generally be around
|
||||
* 6.8MB downloaded but only 5MB saved.
|
||||
*/
|
||||
const val MAX_ATTACHMENT_DOWNLOAD_SIZE = 128 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* How many times should K-9 try to deliver a message before giving up until the app is killed and restarted
|
||||
*/
|
||||
const val MAX_SEND_ATTEMPTS = 5
|
||||
|
||||
const val MANUAL_WAKE_LOCK_TIMEOUT = 120000
|
||||
const val PUSH_WAKE_LOCK_TIMEOUT = K9MailLib.PUSH_WAKE_LOCK_TIMEOUT
|
||||
const val MAIL_SERVICE_WAKE_LOCK_TIMEOUT = 60000
|
||||
const val BOOT_RECEIVER_WAKE_LOCK_TIMEOUT = 60000
|
||||
|
||||
enum class BACKGROUND_OPS {
|
||||
ALWAYS, NEVER, WHEN_CHECKED_AUTO_SYNC
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls behaviour of delete button in notifications.
|
||||
*/
|
||||
enum class NotificationQuickDelete {
|
||||
ALWAYS,
|
||||
FOR_SINGLE_MSG,
|
||||
NEVER
|
||||
}
|
||||
|
||||
enum class LockScreenNotificationVisibility {
|
||||
EVERYTHING,
|
||||
SENDERS,
|
||||
MESSAGE_COUNT,
|
||||
APP_NAME,
|
||||
NOTHING
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls when to use the message list split view.
|
||||
*/
|
||||
enum class SplitViewMode {
|
||||
ALWAYS,
|
||||
NEVER,
|
||||
WHEN_IN_LANDSCAPE
|
||||
}
|
||||
}
|
||||
40
app/core/src/main/java/com/fsck/k9/KoinModule.kt
Normal file
40
app/core/src/main/java/com/fsck/k9/KoinModule.kt
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import android.content.Context
|
||||
import app.k9mail.core.android.common.coreCommonAndroidModule
|
||||
import com.fsck.k9.helper.Contacts
|
||||
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
|
||||
import com.fsck.k9.mailstore.LocalStoreProvider
|
||||
import com.fsck.k9.setup.ServerNameSuggester
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.datetime.Clock
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
|
||||
val mainModule = module {
|
||||
includes(coreCommonAndroidModule)
|
||||
single<CoroutineScope>(named("AppCoroutineScope")) { GlobalScope }
|
||||
single {
|
||||
Preferences(
|
||||
storagePersister = get(),
|
||||
localStoreProvider = get(),
|
||||
accountPreferenceSerializer = get()
|
||||
)
|
||||
}
|
||||
single { get<Context>().resources }
|
||||
single { get<Context>().contentResolver }
|
||||
single { LocalStoreProvider() }
|
||||
single { Contacts() }
|
||||
single { LocalKeyStore(directoryProvider = get()) }
|
||||
single { TrustManagerFactory.createInstance(get()) }
|
||||
single { LocalKeyStoreManager(get()) }
|
||||
single<TrustedSocketFactory> { DefaultTrustedSocketFactory(get(), get()) }
|
||||
single<Clock> { Clock.System }
|
||||
factory { ServerNameSuggester() }
|
||||
factory { EmailAddressValidator() }
|
||||
factory { ServerSettingsSerializer() }
|
||||
}
|
||||
59
app/core/src/main/java/com/fsck/k9/LocalKeyStoreManager.kt
Normal file
59
app/core/src/main/java/com/fsck/k9/LocalKeyStoreManager.kt
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import com.fsck.k9.mail.MailServerDirection
|
||||
import com.fsck.k9.mail.ssl.LocalKeyStore
|
||||
import java.security.cert.CertificateException
|
||||
import java.security.cert.X509Certificate
|
||||
|
||||
class LocalKeyStoreManager(
|
||||
private val localKeyStore: LocalKeyStore
|
||||
) {
|
||||
/**
|
||||
* Add a new certificate for the incoming or outgoing server to the local key store.
|
||||
*/
|
||||
@Throws(CertificateException::class)
|
||||
fun addCertificate(account: Account, direction: MailServerDirection, certificate: X509Certificate) {
|
||||
val serverSettings = if (direction === MailServerDirection.INCOMING) {
|
||||
account.incomingServerSettings
|
||||
} else {
|
||||
account.outgoingServerSettings
|
||||
}
|
||||
localKeyStore.addCertificate(serverSettings.host!!, serverSettings.port, certificate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Examine the existing settings for an account. If the old host/port is different from the
|
||||
* new host/port, then try and delete any (possibly non-existent) certificate stored for the
|
||||
* old host/port.
|
||||
*/
|
||||
fun deleteCertificate(account: Account, newHost: String, newPort: Int, direction: MailServerDirection) {
|
||||
val serverSettings = if (direction === MailServerDirection.INCOMING) {
|
||||
account.incomingServerSettings
|
||||
} else {
|
||||
account.outgoingServerSettings
|
||||
}
|
||||
val oldHost = serverSettings.host!!
|
||||
val oldPort = serverSettings.port
|
||||
if (oldPort == -1) {
|
||||
// This occurs when a new account is created
|
||||
return
|
||||
}
|
||||
if (newHost != oldHost || newPort != oldPort) {
|
||||
localKeyStore.deleteCertificate(oldHost, oldPort)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Examine the settings for the account and attempt to delete (possibly non-existent)
|
||||
* certificates for the incoming and outgoing servers.
|
||||
*/
|
||||
fun deleteCertificates(account: Account) {
|
||||
account.incomingServerSettings.let { serverSettings ->
|
||||
localKeyStore.deleteCertificate(serverSettings.host!!, serverSettings.port)
|
||||
}
|
||||
|
||||
account.outgoingServerSettings.let { serverSettings ->
|
||||
localKeyStore.deleteCertificate(serverSettings.host!!, serverSettings.port)
|
||||
}
|
||||
}
|
||||
}
|
||||
33
app/core/src/main/java/com/fsck/k9/NotificationLight.kt
Normal file
33
app/core/src/main/java/com/fsck/k9/NotificationLight.kt
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import android.app.Notification
|
||||
|
||||
enum class NotificationLight {
|
||||
Disabled,
|
||||
AccountColor,
|
||||
SystemDefaultColor,
|
||||
White,
|
||||
Red,
|
||||
Green,
|
||||
Blue,
|
||||
Yellow,
|
||||
Cyan,
|
||||
Magenta;
|
||||
|
||||
fun toColor(account: Account): Int? {
|
||||
return when (this) {
|
||||
Disabled -> null
|
||||
AccountColor -> account.chipColor.toArgb()
|
||||
SystemDefaultColor -> Notification.COLOR_DEFAULT
|
||||
White -> 0xFFFFFF.toArgb()
|
||||
Red -> 0xFF0000.toArgb()
|
||||
Green -> 0x00FF00.toArgb()
|
||||
Blue -> 0x0000FF.toArgb()
|
||||
Yellow -> 0xFFFF00.toArgb()
|
||||
Cyan -> 0x00FFFF.toArgb()
|
||||
Magenta -> 0xFF00FF.toArgb()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.toArgb() = this or 0xFF000000L.toInt()
|
||||
}
|
||||
11
app/core/src/main/java/com/fsck/k9/NotificationSettings.kt
Normal file
11
app/core/src/main/java/com/fsck/k9/NotificationSettings.kt
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package com.fsck.k9
|
||||
|
||||
/**
|
||||
* Describes how a notification should behave.
|
||||
*/
|
||||
data class NotificationSettings(
|
||||
val isRingEnabled: Boolean = false,
|
||||
val ringtone: String? = null,
|
||||
val light: NotificationLight = NotificationLight.Disabled,
|
||||
val vibration: NotificationVibration = NotificationVibration.DEFAULT
|
||||
)
|
||||
62
app/core/src/main/java/com/fsck/k9/NotificationVibration.kt
Normal file
62
app/core/src/main/java/com/fsck/k9/NotificationVibration.kt
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package com.fsck.k9
|
||||
|
||||
data class NotificationVibration(
|
||||
val isEnabled: Boolean,
|
||||
val pattern: VibratePattern,
|
||||
val repeatCount: Int
|
||||
) {
|
||||
val systemPattern: LongArray
|
||||
get() = getSystemPattern(pattern, repeatCount)
|
||||
|
||||
companion object {
|
||||
val DEFAULT = NotificationVibration(isEnabled = false, pattern = VibratePattern.Default, repeatCount = 5)
|
||||
|
||||
fun getSystemPattern(vibratePattern: VibratePattern, repeatCount: Int): LongArray {
|
||||
val selectedPattern = vibratePattern.vibrationPattern
|
||||
val repeatedPattern = LongArray(selectedPattern.size * repeatCount)
|
||||
for (n in 0 until repeatCount) {
|
||||
System.arraycopy(selectedPattern, 0, repeatedPattern, n * selectedPattern.size, selectedPattern.size)
|
||||
}
|
||||
|
||||
// Do not wait before starting the vibration pattern.
|
||||
repeatedPattern[0] = 0
|
||||
|
||||
return repeatedPattern
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class VibratePattern(
|
||||
/**
|
||||
* These are "off, on" patterns, specified in milliseconds.
|
||||
*/
|
||||
val vibrationPattern: LongArray
|
||||
) {
|
||||
Default(vibrationPattern = longArrayOf(300, 200)),
|
||||
Pattern1(vibrationPattern = longArrayOf(100, 200)),
|
||||
Pattern2(vibrationPattern = longArrayOf(100, 500)),
|
||||
Pattern3(vibrationPattern = longArrayOf(200, 200)),
|
||||
Pattern4(vibrationPattern = longArrayOf(200, 500)),
|
||||
Pattern5(vibrationPattern = longArrayOf(500, 500));
|
||||
|
||||
fun serialize(): Int = when (this) {
|
||||
Default -> 0
|
||||
Pattern1 -> 1
|
||||
Pattern2 -> 2
|
||||
Pattern3 -> 3
|
||||
Pattern4 -> 4
|
||||
Pattern5 -> 5
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun deserialize(value: Int): VibratePattern = when (value) {
|
||||
0 -> Default
|
||||
1 -> Pattern1
|
||||
2 -> Pattern2
|
||||
3 -> Pattern3
|
||||
4 -> Pattern4
|
||||
5 -> Pattern5
|
||||
else -> error("Unknown VibratePattern value: $value")
|
||||
}
|
||||
}
|
||||
}
|
||||
300
app/core/src/main/java/com/fsck/k9/Preferences.kt
Normal file
300
app/core/src/main/java/com/fsck/k9/Preferences.kt
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import androidx.annotation.GuardedBy
|
||||
import androidx.annotation.RestrictTo
|
||||
import com.fsck.k9.mail.MessagingException
|
||||
import com.fsck.k9.mailstore.LocalStoreProvider
|
||||
import com.fsck.k9.preferences.AccountManager
|
||||
import com.fsck.k9.preferences.Storage
|
||||
import com.fsck.k9.preferences.StorageEditor
|
||||
import com.fsck.k9.preferences.StoragePersister
|
||||
import java.util.HashMap
|
||||
import java.util.LinkedList
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CopyOnWriteArraySet
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.buffer
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import timber.log.Timber
|
||||
|
||||
class Preferences internal constructor(
|
||||
private val storagePersister: StoragePersister,
|
||||
private val localStoreProvider: LocalStoreProvider,
|
||||
private val accountPreferenceSerializer: AccountPreferenceSerializer,
|
||||
private val backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
) : AccountManager {
|
||||
private val accountLock = Any()
|
||||
private val storageLock = Any()
|
||||
|
||||
@GuardedBy("accountLock")
|
||||
private var accountsMap: MutableMap<String, Account>? = null
|
||||
|
||||
@GuardedBy("accountLock")
|
||||
private var accountsInOrder = mutableListOf<Account>()
|
||||
|
||||
@GuardedBy("accountLock")
|
||||
private var newAccount: Account? = null
|
||||
private val accountsChangeListeners = CopyOnWriteArraySet<AccountsChangeListener>()
|
||||
private val accountRemovedListeners = CopyOnWriteArraySet<AccountRemovedListener>()
|
||||
|
||||
@GuardedBy("storageLock")
|
||||
private var currentStorage: Storage? = null
|
||||
|
||||
val storage: Storage
|
||||
get() = synchronized(storageLock) {
|
||||
currentStorage ?: storagePersister.loadValues().also { newStorage ->
|
||||
currentStorage = newStorage
|
||||
}
|
||||
}
|
||||
|
||||
fun createStorageEditor(): StorageEditor {
|
||||
return storagePersister.createStorageEditor { updater ->
|
||||
synchronized(storageLock) {
|
||||
currentStorage = updater(storage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RestrictTo(RestrictTo.Scope.TESTS)
|
||||
fun clearAccounts() {
|
||||
synchronized(accountLock) {
|
||||
accountsMap = HashMap()
|
||||
accountsInOrder = LinkedList()
|
||||
}
|
||||
}
|
||||
|
||||
fun loadAccounts() {
|
||||
synchronized(accountLock) {
|
||||
val accounts = mutableMapOf<String, Account>()
|
||||
val accountsInOrder = mutableListOf<Account>()
|
||||
|
||||
val accountUuids = storage.getString("accountUuids", null)
|
||||
if (!accountUuids.isNullOrEmpty()) {
|
||||
accountUuids.split(",").forEach { uuid ->
|
||||
val newAccount = Account(uuid)
|
||||
accountPreferenceSerializer.loadAccount(newAccount, storage)
|
||||
|
||||
accounts[uuid] = newAccount
|
||||
accountsInOrder.add(newAccount)
|
||||
}
|
||||
}
|
||||
|
||||
newAccount?.takeIf { it.accountNumber != -1 }?.let { newAccount ->
|
||||
accounts[newAccount.uuid] = newAccount
|
||||
if (newAccount !in accountsInOrder) {
|
||||
accountsInOrder.add(newAccount)
|
||||
}
|
||||
this.newAccount = null
|
||||
}
|
||||
|
||||
this.accountsMap = accounts
|
||||
this.accountsInOrder = accountsInOrder
|
||||
}
|
||||
}
|
||||
|
||||
val accounts: List<Account>
|
||||
get() {
|
||||
synchronized(accountLock) {
|
||||
if (accountsMap == null) {
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
return accountsInOrder.toList()
|
||||
}
|
||||
}
|
||||
|
||||
private val completeAccounts: List<Account>
|
||||
get() = accounts.filter { it.isFinishedSetup }
|
||||
|
||||
override fun getAccount(accountUuid: String): Account? {
|
||||
synchronized(accountLock) {
|
||||
if (accountsMap == null) {
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
return accountsMap!![accountUuid]
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun getAccountFlow(accountUuid: String): Flow<Account> {
|
||||
return callbackFlow {
|
||||
val initialAccount = getAccount(accountUuid)
|
||||
if (initialAccount == null) {
|
||||
close()
|
||||
return@callbackFlow
|
||||
}
|
||||
|
||||
send(initialAccount)
|
||||
|
||||
val listener = AccountsChangeListener {
|
||||
val account = getAccount(accountUuid)
|
||||
if (account != null) {
|
||||
trySendBlocking(account)
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}
|
||||
addOnAccountsChangeListener(listener)
|
||||
|
||||
awaitClose {
|
||||
removeOnAccountsChangeListener(listener)
|
||||
}
|
||||
}.buffer(capacity = Channel.CONFLATED)
|
||||
.flowOn(backgroundDispatcher)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun getAccountsFlow(): Flow<List<Account>> {
|
||||
return callbackFlow {
|
||||
send(completeAccounts)
|
||||
|
||||
val listener = AccountsChangeListener {
|
||||
trySendBlocking(completeAccounts)
|
||||
}
|
||||
addOnAccountsChangeListener(listener)
|
||||
|
||||
awaitClose {
|
||||
removeOnAccountsChangeListener(listener)
|
||||
}
|
||||
}.buffer(capacity = Channel.CONFLATED)
|
||||
.flowOn(backgroundDispatcher)
|
||||
}
|
||||
|
||||
fun newAccount(): Account {
|
||||
val accountUuid = UUID.randomUUID().toString()
|
||||
val account = Account(accountUuid)
|
||||
accountPreferenceSerializer.loadDefaults(account)
|
||||
|
||||
synchronized(accountLock) {
|
||||
newAccount = account
|
||||
accountsMap!![account.uuid] = account
|
||||
accountsInOrder.add(account)
|
||||
}
|
||||
|
||||
return account
|
||||
}
|
||||
|
||||
fun deleteAccount(account: Account) {
|
||||
synchronized(accountLock) {
|
||||
accountsMap?.remove(account.uuid)
|
||||
accountsInOrder.remove(account)
|
||||
|
||||
val storageEditor = createStorageEditor()
|
||||
accountPreferenceSerializer.delete(storageEditor, storage, account)
|
||||
storageEditor.commit()
|
||||
|
||||
if (account === newAccount) {
|
||||
newAccount = null
|
||||
}
|
||||
}
|
||||
|
||||
notifyAccountRemovedListeners(account)
|
||||
notifyAccountsChangeListeners()
|
||||
}
|
||||
|
||||
val defaultAccount: Account?
|
||||
get() = accounts.firstOrNull()
|
||||
|
||||
override fun saveAccount(account: Account) {
|
||||
ensureAssignedAccountNumber(account)
|
||||
processChangedValues(account)
|
||||
|
||||
synchronized(accountLock) {
|
||||
val editor = createStorageEditor()
|
||||
accountPreferenceSerializer.save(editor, storage, account)
|
||||
editor.commit()
|
||||
}
|
||||
|
||||
notifyAccountsChangeListeners()
|
||||
}
|
||||
|
||||
private fun ensureAssignedAccountNumber(account: Account) {
|
||||
if (account.accountNumber != Account.UNASSIGNED_ACCOUNT_NUMBER) return
|
||||
|
||||
account.accountNumber = generateAccountNumber()
|
||||
}
|
||||
|
||||
private fun processChangedValues(account: Account) {
|
||||
if (account.isChangedVisibleLimits) {
|
||||
try {
|
||||
localStoreProvider.getInstance(account).resetVisibleLimits(account.displayCount)
|
||||
} catch (e: MessagingException) {
|
||||
Timber.e(e, "Failed to load LocalStore!")
|
||||
}
|
||||
}
|
||||
account.resetChangeMarkers()
|
||||
}
|
||||
|
||||
fun generateAccountNumber(): Int {
|
||||
val accountNumbers = accounts.map { it.accountNumber }
|
||||
return findNewAccountNumber(accountNumbers)
|
||||
}
|
||||
|
||||
private fun findNewAccountNumber(accountNumbers: List<Int>): Int {
|
||||
var newAccountNumber = -1
|
||||
for (accountNumber in accountNumbers.sorted()) {
|
||||
if (accountNumber > newAccountNumber + 1) {
|
||||
break
|
||||
}
|
||||
newAccountNumber = accountNumber
|
||||
}
|
||||
newAccountNumber++
|
||||
|
||||
return newAccountNumber
|
||||
}
|
||||
|
||||
override fun moveAccount(account: Account, newPosition: Int) {
|
||||
synchronized(accountLock) {
|
||||
val storageEditor = createStorageEditor()
|
||||
accountPreferenceSerializer.move(storageEditor, account, storage, newPosition)
|
||||
storageEditor.commit()
|
||||
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
notifyAccountsChangeListeners()
|
||||
}
|
||||
|
||||
private fun notifyAccountsChangeListeners() {
|
||||
for (listener in accountsChangeListeners) {
|
||||
listener.onAccountsChanged()
|
||||
}
|
||||
}
|
||||
|
||||
override fun addOnAccountsChangeListener(accountsChangeListener: AccountsChangeListener) {
|
||||
accountsChangeListeners.add(accountsChangeListener)
|
||||
}
|
||||
|
||||
override fun removeOnAccountsChangeListener(accountsChangeListener: AccountsChangeListener) {
|
||||
accountsChangeListeners.remove(accountsChangeListener)
|
||||
}
|
||||
|
||||
private fun notifyAccountRemovedListeners(account: Account) {
|
||||
for (listener in accountRemovedListeners) {
|
||||
listener.onAccountRemoved(account)
|
||||
}
|
||||
}
|
||||
|
||||
override fun addAccountRemovedListener(listener: AccountRemovedListener) {
|
||||
accountRemovedListeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeAccountRemovedListener(listener: AccountRemovedListener) {
|
||||
accountRemovedListeners.remove(listener)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun getPreferences(): Preferences {
|
||||
return DI.get()
|
||||
}
|
||||
}
|
||||
}
|
||||
46
app/core/src/main/java/com/fsck/k9/QuietTimeChecker.java
Normal file
46
app/core/src/main/java/com/fsck/k9/QuietTimeChecker.java
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package com.fsck.k9;
|
||||
|
||||
|
||||
import java.util.Calendar;
|
||||
|
||||
import kotlinx.datetime.Clock;
|
||||
|
||||
|
||||
class QuietTimeChecker {
|
||||
private final Clock clock;
|
||||
private final int quietTimeStart;
|
||||
private final int quietTimeEnd;
|
||||
|
||||
|
||||
QuietTimeChecker(Clock clock, String quietTimeStart, String quietTimeEnd) {
|
||||
this.clock = clock;
|
||||
this.quietTimeStart = parseTime(quietTimeStart);
|
||||
this.quietTimeEnd = parseTime(quietTimeEnd);
|
||||
}
|
||||
|
||||
private static int parseTime(String time) {
|
||||
String[] parts = time.split(":");
|
||||
int hour = Integer.parseInt(parts[0]);
|
||||
int minute = Integer.parseInt(parts[1]);
|
||||
|
||||
return hour * 60 + minute;
|
||||
}
|
||||
|
||||
public boolean isQuietTime() {
|
||||
// If start and end times are the same, we're never quiet
|
||||
if (quietTimeStart == quietTimeEnd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
calendar.setTimeInMillis(clock.now().toEpochMilliseconds());
|
||||
|
||||
int minutesSinceMidnight = (calendar.get(Calendar.HOUR_OF_DAY) * 60) + calendar.get(Calendar.MINUTE);
|
||||
|
||||
if (quietTimeStart > quietTimeEnd) {
|
||||
return minutesSinceMidnight >= quietTimeStart || minutesSinceMidnight <= quietTimeEnd;
|
||||
} else {
|
||||
return minutesSinceMidnight >= quietTimeStart && minutesSinceMidnight <= quietTimeEnd;
|
||||
}
|
||||
}
|
||||
}
|
||||
122
app/core/src/main/java/com/fsck/k9/ServerSettingsSerializer.kt
Normal file
122
app/core/src/main/java/com/fsck/k9/ServerSettingsSerializer.kt
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import com.fsck.k9.mail.AuthType
|
||||
import com.fsck.k9.mail.ConnectionSecurity
|
||||
import com.fsck.k9.mail.ServerSettings
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.squareup.moshi.JsonReader
|
||||
import com.squareup.moshi.JsonReader.Token
|
||||
import com.squareup.moshi.JsonWriter
|
||||
|
||||
class ServerSettingsSerializer {
|
||||
private val adapter = ServerSettingsAdapter()
|
||||
|
||||
fun serialize(serverSettings: ServerSettings): String {
|
||||
return adapter.toJson(serverSettings)
|
||||
}
|
||||
|
||||
fun deserialize(json: String): ServerSettings {
|
||||
return adapter.fromJson(json)!!
|
||||
}
|
||||
}
|
||||
|
||||
private const val KEY_TYPE = "type"
|
||||
private const val KEY_HOST = "host"
|
||||
private const val KEY_PORT = "port"
|
||||
private const val KEY_CONNECTION_SECURITY = "connectionSecurity"
|
||||
private const val KEY_AUTHENTICATION_TYPE = "authenticationType"
|
||||
private const val KEY_USERNAME = "username"
|
||||
private const val KEY_PASSWORD = "password"
|
||||
private const val KEY_CLIENT_CERTIFICATE_ALIAS = "clientCertificateAlias"
|
||||
|
||||
private val JSON_KEYS = JsonReader.Options.of(
|
||||
KEY_TYPE,
|
||||
KEY_HOST,
|
||||
KEY_PORT,
|
||||
KEY_CONNECTION_SECURITY,
|
||||
KEY_AUTHENTICATION_TYPE,
|
||||
KEY_USERNAME,
|
||||
KEY_PASSWORD,
|
||||
KEY_CLIENT_CERTIFICATE_ALIAS
|
||||
)
|
||||
|
||||
private class ServerSettingsAdapter : JsonAdapter<ServerSettings>() {
|
||||
override fun fromJson(reader: JsonReader): ServerSettings {
|
||||
reader.beginObject()
|
||||
|
||||
var type: String? = null
|
||||
var host: String? = null
|
||||
var port: Int? = null
|
||||
var connectionSecurity: ConnectionSecurity? = null
|
||||
var authenticationType: AuthType? = null
|
||||
var username: String? = null
|
||||
var password: String? = null
|
||||
var clientCertificateAlias: String? = null
|
||||
val extra = mutableMapOf<String, String?>()
|
||||
|
||||
while (reader.hasNext()) {
|
||||
when (reader.selectName(JSON_KEYS)) {
|
||||
0 -> type = reader.nextString()
|
||||
1 -> host = reader.nextString()
|
||||
2 -> port = reader.nextInt()
|
||||
3 -> connectionSecurity = ConnectionSecurity.valueOf(reader.nextString())
|
||||
4 -> authenticationType = AuthType.valueOf(reader.nextString())
|
||||
5 -> username = reader.nextString()
|
||||
6 -> password = reader.nextStringOrNull()
|
||||
7 -> clientCertificateAlias = reader.nextStringOrNull()
|
||||
else -> {
|
||||
val key = reader.nextName()
|
||||
val value = reader.nextStringOrNull()
|
||||
extra[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reader.endObject()
|
||||
|
||||
requireNotNull(type) { "'type' must not be missing" }
|
||||
requireNotNull(host) { "'host' must not be missing" }
|
||||
requireNotNull(port) { "'port' must not be missing" }
|
||||
requireNotNull(connectionSecurity) { "'connectionSecurity' must not be missing" }
|
||||
requireNotNull(authenticationType) { "'authenticationType' must not be missing" }
|
||||
requireNotNull(username) { "'username' must not be missing" }
|
||||
|
||||
return ServerSettings(
|
||||
type,
|
||||
host,
|
||||
port,
|
||||
connectionSecurity,
|
||||
authenticationType,
|
||||
username,
|
||||
password,
|
||||
clientCertificateAlias,
|
||||
extra
|
||||
)
|
||||
}
|
||||
|
||||
override fun toJson(writer: JsonWriter, serverSettings: ServerSettings?) {
|
||||
requireNotNull(serverSettings)
|
||||
|
||||
writer.beginObject()
|
||||
writer.serializeNulls = true
|
||||
|
||||
writer.name(KEY_TYPE).value(serverSettings.type)
|
||||
writer.name(KEY_HOST).value(serverSettings.host)
|
||||
writer.name(KEY_PORT).value(serverSettings.port)
|
||||
writer.name(KEY_CONNECTION_SECURITY).value(serverSettings.connectionSecurity.name)
|
||||
writer.name(KEY_AUTHENTICATION_TYPE).value(serverSettings.authenticationType.name)
|
||||
writer.name(KEY_USERNAME).value(serverSettings.username)
|
||||
writer.name(KEY_PASSWORD).value(serverSettings.password)
|
||||
writer.name(KEY_CLIENT_CERTIFICATE_ALIAS).value(serverSettings.clientCertificateAlias)
|
||||
|
||||
for ((key, value) in serverSettings.extra) {
|
||||
writer.name(key).value(value)
|
||||
}
|
||||
|
||||
writer.endObject()
|
||||
}
|
||||
|
||||
private fun JsonReader.nextStringOrNull(): String? {
|
||||
return if (peek() == Token.NULL) nextNull() else nextString()
|
||||
}
|
||||
}
|
||||
44
app/core/src/main/java/com/fsck/k9/StrictMode.kt
Normal file
44
app/core/src/main/java/com/fsck/k9/StrictMode.kt
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import android.os.Build
|
||||
import android.os.StrictMode
|
||||
import android.os.StrictMode.ThreadPolicy
|
||||
import android.os.StrictMode.VmPolicy
|
||||
|
||||
fun enableStrictMode() {
|
||||
StrictMode.setThreadPolicy(createThreadPolicy())
|
||||
StrictMode.setVmPolicy(createVmPolicy())
|
||||
}
|
||||
|
||||
private fun createThreadPolicy(): ThreadPolicy {
|
||||
return ThreadPolicy.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createVmPolicy(): VmPolicy {
|
||||
return VmPolicy.Builder()
|
||||
.detectActivityLeaks()
|
||||
.detectLeakedClosableObjects()
|
||||
.detectLeakedRegistrationObjects()
|
||||
.detectFileUriExposure()
|
||||
.detectLeakedSqlLiteObjects()
|
||||
.apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
detectContentUriWithoutPermission()
|
||||
|
||||
// Disabled because we currently don't use tagged sockets; so this would generate a lot of noise
|
||||
// detectUntaggedSockets()
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
detectCredentialProtectedWhileLocked()
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
detectIncorrectContextUse()
|
||||
detectUnsafeIntentLaunch()
|
||||
}
|
||||
}
|
||||
.penaltyLog()
|
||||
.build()
|
||||
}
|
||||
12
app/core/src/main/java/com/fsck/k9/SwipeAction.kt
Normal file
12
app/core/src/main/java/com/fsck/k9/SwipeAction.kt
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package com.fsck.k9
|
||||
|
||||
enum class SwipeAction(val removesItem: Boolean) {
|
||||
None(removesItem = false),
|
||||
ToggleSelection(removesItem = false),
|
||||
ToggleRead(removesItem = false),
|
||||
ToggleStar(removesItem = false),
|
||||
Archive(removesItem = true),
|
||||
Delete(removesItem = true),
|
||||
Spam(removesItem = true),
|
||||
Move(removesItem = true)
|
||||
}
|
||||
121
app/core/src/main/java/com/fsck/k9/TimberLogger.kt
Normal file
121
app/core/src/main/java/com/fsck/k9/TimberLogger.kt
Normal file
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
7
app/core/src/main/java/com/fsck/k9/UiDensity.kt
Normal file
7
app/core/src/main/java/com/fsck/k9/UiDensity.kt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package com.fsck.k9
|
||||
|
||||
enum class UiDensity {
|
||||
Compact,
|
||||
Default,
|
||||
Relaxed,
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
package com.fsck.k9.autocrypt
|
||||
|
||||
import com.fsck.k9.message.CryptoStatus
|
||||
|
||||
data class AutocryptDraftStateHeader(
|
||||
val isEncrypt: Boolean,
|
||||
val isSignOnly: Boolean,
|
||||
val isReply: Boolean,
|
||||
val isByChoice: Boolean,
|
||||
val isPgpInline: Boolean,
|
||||
val parameters: Map<String, String> = mapOf()
|
||||
) {
|
||||
|
||||
fun toHeaderValue(): String {
|
||||
val builder = StringBuilder()
|
||||
|
||||
builder.append(AutocryptDraftStateHeader.PARAM_ENCRYPT)
|
||||
builder.append(if (isEncrypt) "=yes; " else "=no; ")
|
||||
|
||||
if (isReply) {
|
||||
builder.append(AutocryptDraftStateHeader.PARAM_IS_REPLY).append("=yes; ")
|
||||
}
|
||||
if (isSignOnly) {
|
||||
builder.append(AutocryptDraftStateHeader.PARAM_SIGN_ONLY).append("=yes; ")
|
||||
}
|
||||
if (isByChoice) {
|
||||
builder.append(AutocryptDraftStateHeader.PARAM_BY_CHOICE).append("=yes; ")
|
||||
}
|
||||
if (isPgpInline) {
|
||||
builder.append(AutocryptDraftStateHeader.PARAM_PGP_INLINE).append("=yes; ")
|
||||
}
|
||||
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val AUTOCRYPT_DRAFT_STATE_HEADER = "Autocrypt-Draft-State"
|
||||
|
||||
const val PARAM_ENCRYPT = "encrypt"
|
||||
|
||||
const val PARAM_IS_REPLY = "_is-reply-to-encrypted"
|
||||
const val PARAM_BY_CHOICE = "_by-choice"
|
||||
const val PARAM_PGP_INLINE = "_pgp-inline"
|
||||
const val PARAM_SIGN_ONLY = "_sign-only"
|
||||
|
||||
const val VALUE_YES = "yes"
|
||||
|
||||
@JvmStatic
|
||||
fun fromCryptoStatus(cryptoStatus: CryptoStatus): AutocryptDraftStateHeader {
|
||||
if (cryptoStatus.isSignOnly) {
|
||||
return AutocryptDraftStateHeader(
|
||||
false,
|
||||
true,
|
||||
cryptoStatus.isReplyToEncrypted,
|
||||
cryptoStatus.isUserChoice(),
|
||||
cryptoStatus.isPgpInlineModeEnabled,
|
||||
mapOf()
|
||||
)
|
||||
}
|
||||
return AutocryptDraftStateHeader(
|
||||
cryptoStatus.isEncryptionEnabled,
|
||||
false,
|
||||
cryptoStatus.isReplyToEncrypted,
|
||||
cryptoStatus.isUserChoice(),
|
||||
cryptoStatus.isPgpInlineModeEnabled,
|
||||
mapOf()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.fsck.k9.autocrypt
|
||||
|
||||
import com.fsck.k9.mail.internet.MimeUtility
|
||||
|
||||
class AutocryptDraftStateHeaderParser internal constructor() {
|
||||
|
||||
fun parseAutocryptDraftStateHeader(headerValue: String): AutocryptDraftStateHeader? {
|
||||
val parameters = MimeUtility.getAllHeaderParameters(headerValue)
|
||||
|
||||
val isEncryptStr = parameters.remove(AutocryptDraftStateHeader.PARAM_ENCRYPT) ?: return null
|
||||
val isEncrypt = isEncryptStr == AutocryptDraftStateHeader.VALUE_YES
|
||||
|
||||
val isSignOnlyStr = parameters.remove(AutocryptDraftStateHeader.PARAM_SIGN_ONLY)
|
||||
val isSignOnly = isSignOnlyStr == AutocryptDraftStateHeader.VALUE_YES
|
||||
|
||||
val isReplyStr = parameters.remove(AutocryptDraftStateHeader.PARAM_IS_REPLY)
|
||||
val isReply = isReplyStr == AutocryptDraftStateHeader.VALUE_YES
|
||||
|
||||
val isByChoiceStr = parameters.remove(AutocryptDraftStateHeader.PARAM_BY_CHOICE)
|
||||
val isByChoice = isByChoiceStr == AutocryptDraftStateHeader.VALUE_YES
|
||||
|
||||
val isPgpInlineStr = parameters.remove(AutocryptDraftStateHeader.PARAM_PGP_INLINE)
|
||||
val isPgpInline = isPgpInlineStr == AutocryptDraftStateHeader.VALUE_YES
|
||||
|
||||
if (hasCriticalParameters(parameters)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return AutocryptDraftStateHeader(isEncrypt, isSignOnly, isReply, isByChoice, isPgpInline, parameters)
|
||||
}
|
||||
|
||||
private fun hasCriticalParameters(parameters: Map<String, String>): Boolean {
|
||||
for (parameterName in parameters.keys) {
|
||||
if (!parameterName.startsWith("_")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package com.fsck.k9.autocrypt;
|
||||
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
|
||||
class AutocryptGossipHeader {
|
||||
static final String AUTOCRYPT_GOSSIP_HEADER = "Autocrypt-Gossip";
|
||||
|
||||
private static final String AUTOCRYPT_PARAM_ADDR = "addr";
|
||||
private static final String AUTOCRYPT_PARAM_KEY_DATA = "keydata";
|
||||
|
||||
|
||||
@NonNull
|
||||
final byte[] keyData;
|
||||
@NonNull
|
||||
final String addr;
|
||||
|
||||
AutocryptGossipHeader(@NonNull String addr, @NonNull byte[] keyData) {
|
||||
this.addr = addr;
|
||||
this.keyData = keyData;
|
||||
}
|
||||
|
||||
String toRawHeaderString() {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
|
||||
builder.append(AutocryptGossipHeader.AUTOCRYPT_GOSSIP_HEADER).append(": ");
|
||||
builder.append(AutocryptGossipHeader.AUTOCRYPT_PARAM_ADDR).append('=').append(addr).append("; ");
|
||||
builder.append(AutocryptGossipHeader.AUTOCRYPT_PARAM_KEY_DATA).append('=');
|
||||
builder.append(AutocryptHeader.createFoldedBase64KeyData(keyData));
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
AutocryptGossipHeader that = (AutocryptGossipHeader) o;
|
||||
|
||||
if (!Arrays.equals(keyData, that.keyData)) {
|
||||
return false;
|
||||
}
|
||||
return addr.equals(that.addr);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = Arrays.hashCode(keyData);
|
||||
result = 31 * result + addr.hashCode();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
package com.fsck.k9.autocrypt;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
|
||||
import com.fsck.k9.mail.Part;
|
||||
import com.fsck.k9.mail.internet.MimeUtility;
|
||||
import okio.ByteString;
|
||||
import timber.log.Timber;
|
||||
|
||||
|
||||
class AutocryptGossipHeaderParser {
|
||||
private static final AutocryptGossipHeaderParser INSTANCE = new AutocryptGossipHeaderParser();
|
||||
|
||||
|
||||
public static AutocryptGossipHeaderParser getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
private AutocryptGossipHeaderParser() { }
|
||||
|
||||
|
||||
List<AutocryptGossipHeader> getAllAutocryptGossipHeaders(Part part) {
|
||||
String[] headers = part.getHeader(AutocryptGossipHeader.AUTOCRYPT_GOSSIP_HEADER);
|
||||
List<AutocryptGossipHeader> autocryptHeaders = parseAllAutocryptGossipHeaders(headers);
|
||||
|
||||
return Collections.unmodifiableList(autocryptHeaders);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@VisibleForTesting
|
||||
AutocryptGossipHeader parseAutocryptGossipHeader(String headerValue) {
|
||||
Map<String,String> parameters = MimeUtility.getAllHeaderParameters(headerValue);
|
||||
|
||||
String type = parameters.remove(AutocryptHeader.AUTOCRYPT_PARAM_TYPE);
|
||||
if (type != null && !type.equals(AutocryptHeader.AUTOCRYPT_TYPE_1)) {
|
||||
Timber.e("autocrypt: unsupported type parameter %s", type);
|
||||
return null;
|
||||
}
|
||||
|
||||
String base64KeyData = parameters.remove(AutocryptHeader.AUTOCRYPT_PARAM_KEY_DATA);
|
||||
if (base64KeyData == null) {
|
||||
Timber.e("autocrypt: missing key parameter");
|
||||
return null;
|
||||
}
|
||||
|
||||
ByteString byteString = ByteString.decodeBase64(base64KeyData);
|
||||
if (byteString == null) {
|
||||
Timber.e("autocrypt: error parsing base64 data");
|
||||
return null;
|
||||
}
|
||||
|
||||
String addr = parameters.remove(AutocryptHeader.AUTOCRYPT_PARAM_ADDR);
|
||||
if (addr == null) {
|
||||
Timber.e("autocrypt: no to header!");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hasCriticalParameters(parameters)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AutocryptGossipHeader(addr, byteString.toByteArray());
|
||||
}
|
||||
|
||||
private boolean hasCriticalParameters(Map<String, String> parameters) {
|
||||
for (String parameterName : parameters.keySet()) {
|
||||
if (parameterName != null && !parameterName.startsWith("_")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private List<AutocryptGossipHeader> parseAllAutocryptGossipHeaders(String[] headers) {
|
||||
ArrayList<AutocryptGossipHeader> autocryptHeaders = new ArrayList<>();
|
||||
for (String header : headers) {
|
||||
AutocryptGossipHeader autocryptHeader = parseAutocryptGossipHeader(header);
|
||||
if (autocryptHeader == null) {
|
||||
Timber.e("Encountered malformed autocrypt-gossip header - skipping!");
|
||||
continue;
|
||||
}
|
||||
autocryptHeaders.add(autocryptHeader);
|
||||
}
|
||||
return autocryptHeaders;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
package com.fsck.k9.autocrypt;
|
||||
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import okio.ByteString;
|
||||
|
||||
|
||||
class AutocryptHeader {
|
||||
static final String AUTOCRYPT_HEADER = "Autocrypt";
|
||||
|
||||
static final String AUTOCRYPT_PARAM_ADDR = "addr";
|
||||
static final String AUTOCRYPT_PARAM_KEY_DATA = "keydata";
|
||||
|
||||
static final String AUTOCRYPT_PARAM_TYPE = "type";
|
||||
static final String AUTOCRYPT_TYPE_1 = "1";
|
||||
|
||||
static final String AUTOCRYPT_PARAM_PREFER_ENCRYPT = "prefer-encrypt";
|
||||
static final String AUTOCRYPT_PREFER_ENCRYPT_MUTUAL = "mutual";
|
||||
|
||||
private static final int HEADER_LINE_LENGTH = 76;
|
||||
|
||||
|
||||
@NonNull
|
||||
final byte[] keyData;
|
||||
@NonNull
|
||||
final String addr;
|
||||
@NonNull
|
||||
final Map<String,String> parameters;
|
||||
final boolean isPreferEncryptMutual;
|
||||
|
||||
AutocryptHeader(@NonNull Map<String, String> parameters, @NonNull String addr,
|
||||
@NonNull byte[] keyData, boolean isPreferEncryptMutual) {
|
||||
this.parameters = parameters;
|
||||
this.addr = addr;
|
||||
this.keyData = keyData;
|
||||
this.isPreferEncryptMutual = isPreferEncryptMutual;
|
||||
}
|
||||
|
||||
String toRawHeaderString() {
|
||||
// TODO we don't properly fold lines here. if we want to support parameters, we need to do that somehow
|
||||
if (!parameters.isEmpty()) {
|
||||
throw new UnsupportedOperationException("arbitrary parameters not supported");
|
||||
}
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append(AutocryptHeader.AUTOCRYPT_HEADER).append(": ");
|
||||
builder.append(AutocryptHeader.AUTOCRYPT_PARAM_ADDR).append('=').append(addr).append("; ");
|
||||
if (isPreferEncryptMutual) {
|
||||
builder.append(AutocryptHeader.AUTOCRYPT_PARAM_PREFER_ENCRYPT)
|
||||
.append('=').append(AutocryptHeader.AUTOCRYPT_PREFER_ENCRYPT_MUTUAL).append("; ");
|
||||
}
|
||||
builder.append(AutocryptHeader.AUTOCRYPT_PARAM_KEY_DATA).append("=");
|
||||
builder.append(createFoldedBase64KeyData(keyData));
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
static String createFoldedBase64KeyData(byte[] keyData) {
|
||||
String base64KeyData = ByteString.of(keyData).base64();
|
||||
StringBuilder result = new StringBuilder();
|
||||
|
||||
for (int i = 0, base64Length = base64KeyData.length(); i < base64Length; i += HEADER_LINE_LENGTH) {
|
||||
if (i + HEADER_LINE_LENGTH <= base64Length) {
|
||||
result.append("\r\n ");
|
||||
result.append(base64KeyData, i, i + HEADER_LINE_LENGTH);
|
||||
} else {
|
||||
result.append("\r\n ");
|
||||
result.append(base64KeyData, i, base64Length);
|
||||
}
|
||||
}
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
AutocryptHeader that = (AutocryptHeader) o;
|
||||
|
||||
return isPreferEncryptMutual == that.isPreferEncryptMutual && Arrays.equals(keyData, that.keyData)
|
||||
&& addr.equals(that.addr) && parameters.equals(that.parameters);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = Arrays.hashCode(keyData);
|
||||
result = 31 * result + addr.hashCode();
|
||||
result = 31 * result + parameters.hashCode();
|
||||
result = 31 * result + (isPreferEncryptMutual ? 1 : 0);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
package com.fsck.k9.autocrypt;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Map;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
|
||||
import com.fsck.k9.mail.Message;
|
||||
import com.fsck.k9.mail.internet.MimeUtility;
|
||||
import okio.ByteString;
|
||||
import timber.log.Timber;
|
||||
|
||||
|
||||
class AutocryptHeaderParser {
|
||||
private static final AutocryptHeaderParser INSTANCE = new AutocryptHeaderParser();
|
||||
|
||||
|
||||
public static AutocryptHeaderParser getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
private AutocryptHeaderParser() { }
|
||||
|
||||
|
||||
@Nullable
|
||||
AutocryptHeader getValidAutocryptHeader(Message currentMessage) {
|
||||
String[] headers = currentMessage.getHeader(AutocryptHeader.AUTOCRYPT_HEADER);
|
||||
ArrayList<AutocryptHeader> autocryptHeaders = parseAllAutocryptHeaders(headers);
|
||||
|
||||
boolean isSingleValidHeader = autocryptHeaders.size() == 1;
|
||||
return isSingleValidHeader ? autocryptHeaders.get(0) : null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@VisibleForTesting
|
||||
AutocryptHeader parseAutocryptHeader(String headerValue) {
|
||||
Map<String,String> parameters = MimeUtility.getAllHeaderParameters(headerValue);
|
||||
|
||||
String type = parameters.remove(AutocryptHeader.AUTOCRYPT_PARAM_TYPE);
|
||||
if (type != null && !type.equals(AutocryptHeader.AUTOCRYPT_TYPE_1)) {
|
||||
Timber.e("autocrypt: unsupported type parameter %s", type);
|
||||
return null;
|
||||
}
|
||||
|
||||
String base64KeyData = parameters.remove(AutocryptHeader.AUTOCRYPT_PARAM_KEY_DATA);
|
||||
if (base64KeyData == null) {
|
||||
Timber.e("autocrypt: missing key parameter");
|
||||
return null;
|
||||
}
|
||||
|
||||
ByteString byteString = ByteString.decodeBase64(base64KeyData);
|
||||
if (byteString == null) {
|
||||
Timber.e("autocrypt: error parsing base64 data");
|
||||
return null;
|
||||
}
|
||||
|
||||
String to = parameters.remove(AutocryptHeader.AUTOCRYPT_PARAM_ADDR);
|
||||
if (to == null) {
|
||||
Timber.e("autocrypt: no to header!");
|
||||
return null;
|
||||
}
|
||||
|
||||
boolean isPreferEncryptMutual = false;
|
||||
String preferEncrypt = parameters.remove(AutocryptHeader.AUTOCRYPT_PARAM_PREFER_ENCRYPT);
|
||||
if (AutocryptHeader.AUTOCRYPT_PREFER_ENCRYPT_MUTUAL.equalsIgnoreCase(preferEncrypt)) {
|
||||
isPreferEncryptMutual = true;
|
||||
}
|
||||
|
||||
if (hasCriticalParameters(parameters)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AutocryptHeader(parameters, to, byteString.toByteArray(), isPreferEncryptMutual);
|
||||
}
|
||||
|
||||
private boolean hasCriticalParameters(Map<String, String> parameters) {
|
||||
for (String parameterName : parameters.keySet()) {
|
||||
if (parameterName != null && !parameterName.startsWith("_")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private ArrayList<AutocryptHeader> parseAllAutocryptHeaders(String[] headers) {
|
||||
ArrayList<AutocryptHeader> autocryptHeaders = new ArrayList<>();
|
||||
for (String header : headers) {
|
||||
AutocryptHeader autocryptHeader = parseAutocryptHeader(header);
|
||||
if (autocryptHeader != null) {
|
||||
autocryptHeaders.add(autocryptHeader);
|
||||
}
|
||||
}
|
||||
return autocryptHeaders;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package com.fsck.k9.autocrypt;
|
||||
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
|
||||
import android.content.Intent;
|
||||
|
||||
import org.openintents.openpgp.util.OpenPgpApi;
|
||||
|
||||
|
||||
public class AutocryptOpenPgpApiInteractor {
|
||||
public static AutocryptOpenPgpApiInteractor getInstance() {
|
||||
return new AutocryptOpenPgpApiInteractor();
|
||||
}
|
||||
|
||||
private AutocryptOpenPgpApiInteractor() { }
|
||||
|
||||
public byte[] getKeyMaterialForKeyId(OpenPgpApi openPgpApi, long keyId, String minimizeForUserId) {
|
||||
Intent retrieveKeyIntent = new Intent(OpenPgpApi.ACTION_GET_KEY);
|
||||
retrieveKeyIntent.putExtra(OpenPgpApi.EXTRA_KEY_ID, keyId);
|
||||
return getKeyMaterialFromApi(openPgpApi, retrieveKeyIntent, minimizeForUserId);
|
||||
}
|
||||
|
||||
public byte[] getKeyMaterialForUserId(OpenPgpApi openPgpApi, String userId) {
|
||||
Intent retrieveKeyIntent = new Intent(OpenPgpApi.ACTION_GET_KEY);
|
||||
retrieveKeyIntent.putExtra(OpenPgpApi.EXTRA_USER_ID, userId);
|
||||
return getKeyMaterialFromApi(openPgpApi, retrieveKeyIntent, userId);
|
||||
}
|
||||
|
||||
private byte[] getKeyMaterialFromApi(OpenPgpApi openPgpApi, Intent retrieveKeyIntent, String userId) {
|
||||
retrieveKeyIntent.putExtra(OpenPgpApi.EXTRA_MINIMIZE, true);
|
||||
retrieveKeyIntent.putExtra(OpenPgpApi.EXTRA_MINIMIZE_USER_ID, userId);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
Intent result = openPgpApi.executeApi(retrieveKeyIntent, (InputStream) null, baos);
|
||||
|
||||
if (result.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR) ==
|
||||
OpenPgpApi.RESULT_CODE_SUCCESS) {
|
||||
return baos.toByteArray();
|
||||
} else{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
package com.fsck.k9.autocrypt;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
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.MimeBodyPart;
|
||||
import org.openintents.openpgp.AutocryptPeerUpdate;
|
||||
import org.openintents.openpgp.util.OpenPgpApi;
|
||||
|
||||
|
||||
public class AutocryptOperations {
|
||||
private final AutocryptHeaderParser autocryptHeaderParser;
|
||||
private final AutocryptGossipHeaderParser autocryptGossipHeaderParser;
|
||||
|
||||
|
||||
public static AutocryptOperations getInstance() {
|
||||
AutocryptHeaderParser autocryptHeaderParser = AutocryptHeaderParser.getInstance();
|
||||
AutocryptGossipHeaderParser autocryptGossipHeaderParser = AutocryptGossipHeaderParser.getInstance();
|
||||
return new AutocryptOperations(autocryptHeaderParser, autocryptGossipHeaderParser);
|
||||
}
|
||||
|
||||
|
||||
private AutocryptOperations(AutocryptHeaderParser autocryptHeaderParser,
|
||||
AutocryptGossipHeaderParser autocryptGossipHeaderParser) {
|
||||
this.autocryptHeaderParser = autocryptHeaderParser;
|
||||
this.autocryptGossipHeaderParser = autocryptGossipHeaderParser;
|
||||
}
|
||||
|
||||
public boolean addAutocryptPeerUpdateToIntentIfPresent(Message currentMessage, Intent intent) {
|
||||
AutocryptHeader autocryptHeader = autocryptHeaderParser.getValidAutocryptHeader(currentMessage);
|
||||
if (autocryptHeader == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String messageFromAddress = currentMessage.getFrom()[0].getAddress();
|
||||
if (!autocryptHeader.addr.equalsIgnoreCase(messageFromAddress)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Date messageDate = currentMessage.getSentDate();
|
||||
Date internalDate = currentMessage.getInternalDate();
|
||||
Date effectiveDate = messageDate.before(internalDate) ? messageDate : internalDate;
|
||||
|
||||
AutocryptPeerUpdate data = AutocryptPeerUpdate.create(
|
||||
autocryptHeader.keyData, effectiveDate, autocryptHeader.isPreferEncryptMutual);
|
||||
intent.putExtra(OpenPgpApi.EXTRA_AUTOCRYPT_PEER_ID, messageFromAddress);
|
||||
intent.putExtra(OpenPgpApi.EXTRA_AUTOCRYPT_PEER_UPDATE, data);
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean addAutocryptGossipUpdateToIntentIfPresent(Message message, MimeBodyPart decryptedPart, Intent intent) {
|
||||
Bundle updates = createGossipUpdateBundle(message, decryptedPart);
|
||||
|
||||
if (updates == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
intent.putExtra(OpenPgpApi.EXTRA_AUTOCRYPT_PEER_GOSSIP_UPDATES, updates);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private Bundle createGossipUpdateBundle(Message message, MimeBodyPart decryptedPart) {
|
||||
List<String> gossipAcceptedAddresses = getGossipAcceptedAddresses(message);
|
||||
if (gossipAcceptedAddresses.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<AutocryptGossipHeader> autocryptGossipHeaders =
|
||||
autocryptGossipHeaderParser.getAllAutocryptGossipHeaders(decryptedPart);
|
||||
if (autocryptGossipHeaders.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Date messageDate = message.getSentDate();
|
||||
Date internalDate = message.getInternalDate();
|
||||
Date effectiveDate = messageDate.before(internalDate) ? messageDate : internalDate;
|
||||
|
||||
return createGossipUpdateBundle(gossipAcceptedAddresses, autocryptGossipHeaders, effectiveDate);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private Bundle createGossipUpdateBundle(List<String> gossipAcceptedAddresses,
|
||||
List<AutocryptGossipHeader> autocryptGossipHeaders, Date effectiveDate) {
|
||||
Bundle updates = new Bundle();
|
||||
for (AutocryptGossipHeader autocryptGossipHeader : autocryptGossipHeaders) {
|
||||
String normalizedAddress = autocryptGossipHeader.addr.toLowerCase(Locale.ROOT);
|
||||
boolean isAcceptedAddress = gossipAcceptedAddresses.contains(normalizedAddress);
|
||||
if (!isAcceptedAddress) {
|
||||
continue;
|
||||
}
|
||||
|
||||
AutocryptPeerUpdate update = AutocryptPeerUpdate.create(autocryptGossipHeader.keyData, effectiveDate, false);
|
||||
updates.putParcelable(autocryptGossipHeader.addr, update);
|
||||
}
|
||||
if (updates.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return updates;
|
||||
}
|
||||
|
||||
private List<String> getGossipAcceptedAddresses(Message message) {
|
||||
ArrayList<String> result = new ArrayList<>();
|
||||
|
||||
addRecipientsToList(result, message, RecipientType.TO);
|
||||
addRecipientsToList(result, message, RecipientType.CC);
|
||||
removeRecipientsFromList(result, message, RecipientType.DELIVERED_TO);
|
||||
|
||||
return Collections.unmodifiableList(result);
|
||||
}
|
||||
|
||||
private void addRecipientsToList(ArrayList<String> result, Message message, RecipientType recipientType) {
|
||||
for (Address address : message.getRecipients(recipientType)) {
|
||||
String addr = address.getAddress();
|
||||
if (addr != null) {
|
||||
result.add(addr.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void removeRecipientsFromList(ArrayList<String> result, Message message, RecipientType recipientType) {
|
||||
for (Address address : message.getRecipients(recipientType)) {
|
||||
String addr = address.getAddress();
|
||||
if (addr != null) {
|
||||
result.remove(addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean hasAutocryptHeader(Message currentMessage) {
|
||||
return currentMessage.getHeader(AutocryptHeader.AUTOCRYPT_HEADER).length > 0;
|
||||
}
|
||||
|
||||
public boolean hasAutocryptGossipHeader(MimeBodyPart part) {
|
||||
return part.getHeader(AutocryptGossipHeader.AUTOCRYPT_GOSSIP_HEADER).length > 0;
|
||||
}
|
||||
|
||||
public void addAutocryptHeaderToMessage(Message message, byte[] keyData,
|
||||
String autocryptAddress, boolean preferEncryptMutual) {
|
||||
AutocryptHeader autocryptHeader = new AutocryptHeader(
|
||||
Collections.<String,String>emptyMap(), autocryptAddress, keyData, preferEncryptMutual);
|
||||
String rawAutocryptHeader = autocryptHeader.toRawHeaderString();
|
||||
|
||||
message.addRawHeader(AutocryptHeader.AUTOCRYPT_HEADER, rawAutocryptHeader);
|
||||
}
|
||||
|
||||
public void addAutocryptGossipHeaderToPart(MimeBodyPart part, byte[] keyData, String autocryptAddress) {
|
||||
AutocryptGossipHeader autocryptGossipHeader = new AutocryptGossipHeader(autocryptAddress, keyData);
|
||||
String rawAutocryptHeader = autocryptGossipHeader.toRawHeaderString();
|
||||
|
||||
part.addRawHeader(AutocryptGossipHeader.AUTOCRYPT_GOSSIP_HEADER, rawAutocryptHeader);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.fsck.k9.autocrypt
|
||||
|
||||
interface AutocryptStringProvider {
|
||||
fun transferMessageSubject(): String
|
||||
fun transferMessageBody(): String
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package com.fsck.k9.autocrypt
|
||||
|
||||
import com.fsck.k9.K9
|
||||
import com.fsck.k9.mail.Address
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.mail.MessagingException
|
||||
import com.fsck.k9.mail.internet.MimeBodyPart
|
||||
import com.fsck.k9.mail.internet.MimeHeader
|
||||
import com.fsck.k9.mail.internet.MimeMessage
|
||||
import com.fsck.k9.mail.internet.MimeMessageHelper
|
||||
import com.fsck.k9.mail.internet.MimeMultipart
|
||||
import com.fsck.k9.mail.internet.TextBody
|
||||
import com.fsck.k9.mailstore.BinaryMemoryBody
|
||||
import java.util.Date
|
||||
|
||||
class AutocryptTransferMessageCreator(private val stringProvider: AutocryptStringProvider) {
|
||||
fun createAutocryptTransferMessage(data: ByteArray, address: Address): Message {
|
||||
try {
|
||||
val subjectText = stringProvider.transferMessageSubject()
|
||||
val messageText = stringProvider.transferMessageBody()
|
||||
|
||||
val textBodyPart = MimeBodyPart.create(TextBody(messageText))
|
||||
val dataBodyPart = MimeBodyPart.create(BinaryMemoryBody(data, "7bit"))
|
||||
dataBodyPart.setHeader(MimeHeader.HEADER_CONTENT_TYPE, "application/autocrypt-setup")
|
||||
dataBodyPart.setHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, "attachment; filename=\"autocrypt-setup-message\"")
|
||||
|
||||
val messageBody = MimeMultipart.newInstance()
|
||||
messageBody.addBodyPart(textBodyPart)
|
||||
messageBody.addBodyPart(dataBodyPart)
|
||||
|
||||
val message = MimeMessage.create()
|
||||
MimeMessageHelper.setBody(message, messageBody)
|
||||
|
||||
val nowDate = Date()
|
||||
|
||||
message.setFlag(Flag.X_DOWNLOADED_FULL, true)
|
||||
message.subject = subjectText
|
||||
message.setHeader("Autocrypt-Setup-Message", "v1")
|
||||
message.internalDate = nowDate
|
||||
message.addSentDate(nowDate, K9.isHideTimeZone)
|
||||
message.setFrom(address)
|
||||
message.setHeader("To", address.toEncodedString())
|
||||
|
||||
return message
|
||||
} catch (e: MessagingException) {
|
||||
throw AssertionError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.fsck.k9.autocrypt
|
||||
|
||||
import org.koin.dsl.module
|
||||
|
||||
val autocryptModule = module {
|
||||
single { AutocryptTransferMessageCreator(get()) }
|
||||
single { AutocryptDraftStateHeaderParser() }
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.fsck.k9.backend
|
||||
|
||||
import com.fsck.k9.Account
|
||||
import com.fsck.k9.backend.api.Backend
|
||||
|
||||
interface BackendFactory {
|
||||
fun createBackend(account: Account): Backend
|
||||
}
|
||||
75
app/core/src/main/java/com/fsck/k9/backend/BackendManager.kt
Normal file
75
app/core/src/main/java/com/fsck/k9/backend/BackendManager.kt
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package com.fsck.k9.backend
|
||||
|
||||
import com.fsck.k9.Account
|
||||
import com.fsck.k9.backend.api.Backend
|
||||
import com.fsck.k9.mail.ServerSettings
|
||||
import java.util.concurrent.CopyOnWriteArraySet
|
||||
|
||||
class BackendManager(private val backendFactories: Map<String, BackendFactory>) {
|
||||
private val backendCache = mutableMapOf<String, BackendContainer>()
|
||||
private val listeners = CopyOnWriteArraySet<BackendChangedListener>()
|
||||
|
||||
fun getBackend(account: Account): Backend {
|
||||
val newBackend = synchronized(backendCache) {
|
||||
val container = backendCache[account.uuid]
|
||||
if (container != null && isBackendStillValid(container, account)) {
|
||||
return container.backend
|
||||
}
|
||||
|
||||
createBackend(account).also { backend ->
|
||||
backendCache[account.uuid] = BackendContainer(
|
||||
backend,
|
||||
account.incomingServerSettings,
|
||||
account.outgoingServerSettings
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners(account)
|
||||
|
||||
return newBackend
|
||||
}
|
||||
|
||||
private fun isBackendStillValid(container: BackendContainer, account: Account): Boolean {
|
||||
return container.incomingServerSettings == account.incomingServerSettings &&
|
||||
container.outgoingServerSettings == account.outgoingServerSettings
|
||||
}
|
||||
|
||||
fun removeBackend(account: Account) {
|
||||
synchronized(backendCache) {
|
||||
backendCache.remove(account.uuid)
|
||||
}
|
||||
|
||||
notifyListeners(account)
|
||||
}
|
||||
|
||||
private fun createBackend(account: Account): Backend {
|
||||
val serverType = account.incomingServerSettings.type
|
||||
val backendFactory = backendFactories[serverType] ?: error("Unsupported account type")
|
||||
return backendFactory.createBackend(account)
|
||||
}
|
||||
|
||||
fun addListener(listener: BackendChangedListener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeListener(listener: BackendChangedListener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
private fun notifyListeners(account: Account) {
|
||||
for (listener in listeners) {
|
||||
listener.onBackendChanged(account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class BackendContainer(
|
||||
val backend: Backend,
|
||||
val incomingServerSettings: ServerSettings,
|
||||
val outgoingServerSettings: ServerSettings
|
||||
)
|
||||
|
||||
fun interface BackendChangedListener {
|
||||
fun onBackendChanged(account: Account)
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package com.fsck.k9.contact
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.ContactsContract
|
||||
import com.fsck.k9.mail.Address
|
||||
|
||||
object ContactIntentHelper {
|
||||
@JvmStatic
|
||||
fun getContactPickerIntent(): Intent {
|
||||
return Intent(Intent.ACTION_PICK, ContactsContract.CommonDataKinds.Email.CONTENT_URI)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Intent to add information to an existing contact or add a new one.
|
||||
*
|
||||
* @param address An {@link Address} instance containing the email address
|
||||
* of the entity you want to add to the contacts. Optionally
|
||||
* the instance also contains the (display) name of that
|
||||
* entity.
|
||||
*/
|
||||
fun getAddEmailContactIntent(address: Address): Intent {
|
||||
return Intent(ContactsContract.Intents.SHOW_OR_CREATE_CONTACT).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
data = Uri.fromParts("mailto", address.address, null)
|
||||
putExtra(ContactsContract.Intents.EXTRA_CREATE_DESCRIPTION, address.toString())
|
||||
|
||||
if (address.personal != null) {
|
||||
putExtra(ContactsContract.Intents.Insert.NAME, address.personal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Intent to add a phone number to an existing contact or add a new one.
|
||||
*
|
||||
* @param phoneNumber
|
||||
* The phone number to add to a contact, or to use when creating a new contact.
|
||||
*/
|
||||
fun getAddPhoneContactIntent(phoneNumber: String): Intent {
|
||||
return Intent(Intent.ACTION_INSERT_OR_EDIT).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
type = ContactsContract.Contacts.CONTENT_ITEM_TYPE
|
||||
putExtra(ContactsContract.Intents.Insert.PHONE, Uri.decode(phoneNumber))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.fsck.k9.controller
|
||||
|
||||
import com.fsck.k9.backend.BackendManager
|
||||
|
||||
interface ControllerExtension {
|
||||
fun init(controller: MessagingController, backendManager: BackendManager, controllerInternals: ControllerInternals)
|
||||
|
||||
interface ControllerInternals {
|
||||
fun put(description: String, listener: MessagingListener?, runnable: Runnable)
|
||||
fun putBackground(description: String, listener: MessagingListener?, runnable: Runnable)
|
||||
}
|
||||
}
|
||||
177
app/core/src/main/java/com/fsck/k9/controller/DraftOperations.kt
Normal file
177
app/core/src/main/java/com/fsck/k9/controller/DraftOperations.kt
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
package com.fsck.k9.controller
|
||||
|
||||
import com.fsck.k9.Account
|
||||
import com.fsck.k9.Account.Expunge
|
||||
import com.fsck.k9.K9
|
||||
import com.fsck.k9.backend.api.Backend
|
||||
import com.fsck.k9.controller.MessagingControllerCommands.PendingAppend
|
||||
import com.fsck.k9.controller.MessagingControllerCommands.PendingReplace
|
||||
import com.fsck.k9.mail.FetchProfile
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.mail.MessageDownloadState
|
||||
import com.fsck.k9.mail.MessagingException
|
||||
import com.fsck.k9.mailstore.LocalFolder
|
||||
import com.fsck.k9.mailstore.LocalMessage
|
||||
import com.fsck.k9.mailstore.MessageStoreManager
|
||||
import com.fsck.k9.mailstore.SaveMessageData
|
||||
import com.fsck.k9.mailstore.SaveMessageDataCreator
|
||||
import org.jetbrains.annotations.NotNull
|
||||
import timber.log.Timber
|
||||
|
||||
internal class DraftOperations(
|
||||
private val messagingController: @NotNull MessagingController,
|
||||
private val messageStoreManager: @NotNull MessageStoreManager,
|
||||
private val saveMessageDataCreator: SaveMessageDataCreator
|
||||
) {
|
||||
|
||||
fun saveDraft(
|
||||
account: Account,
|
||||
message: Message,
|
||||
existingDraftId: Long?,
|
||||
plaintextSubject: String?
|
||||
): Long? {
|
||||
return try {
|
||||
val draftsFolderId = account.draftsFolderId ?: error("No Drafts folder configured")
|
||||
|
||||
val messageId = if (messagingController.supportsUpload(account)) {
|
||||
saveAndUploadDraft(account, message, draftsFolderId, existingDraftId, plaintextSubject)
|
||||
} else {
|
||||
saveDraftLocally(account, message, draftsFolderId, existingDraftId, plaintextSubject)
|
||||
}
|
||||
|
||||
messageId
|
||||
} catch (e: MessagingException) {
|
||||
Timber.e(e, "Unable to save message as draft.")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveAndUploadDraft(
|
||||
account: Account,
|
||||
message: Message,
|
||||
folderId: Long,
|
||||
existingDraftId: Long?,
|
||||
subject: String?
|
||||
): Long {
|
||||
val messageStore = messageStoreManager.getMessageStore(account)
|
||||
|
||||
val messageId = messageStore.saveLocalMessage(folderId, message.toSaveMessageData(subject))
|
||||
|
||||
val previousDraftMessage = if (existingDraftId != null) {
|
||||
val localStore = messagingController.getLocalStoreOrThrow(account)
|
||||
val localFolder = localStore.getFolder(folderId)
|
||||
localFolder.open()
|
||||
|
||||
localFolder.getMessage(existingDraftId)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (previousDraftMessage != null) {
|
||||
previousDraftMessage.delete()
|
||||
|
||||
val deleteMessageId = previousDraftMessage.databaseId
|
||||
val command = PendingReplace.create(folderId, messageId, deleteMessageId)
|
||||
messagingController.queuePendingCommand(account, command)
|
||||
} else {
|
||||
val fakeMessageServerId = messageStore.getMessageServerId(messageId)
|
||||
if (fakeMessageServerId != null) {
|
||||
val command = PendingAppend.create(folderId, fakeMessageServerId)
|
||||
messagingController.queuePendingCommand(account, command)
|
||||
}
|
||||
}
|
||||
|
||||
messagingController.processPendingCommands(account)
|
||||
|
||||
return messageId
|
||||
}
|
||||
|
||||
private fun saveDraftLocally(
|
||||
account: Account,
|
||||
message: Message,
|
||||
folderId: Long,
|
||||
existingDraftId: Long?,
|
||||
plaintextSubject: String?
|
||||
): Long {
|
||||
val messageStore = messageStoreManager.getMessageStore(account)
|
||||
val messageData = message.toSaveMessageData(plaintextSubject)
|
||||
|
||||
return messageStore.saveLocalMessage(folderId, messageData, existingDraftId)
|
||||
}
|
||||
|
||||
fun processPendingReplace(command: PendingReplace, account: Account) {
|
||||
val localStore = messagingController.getLocalStoreOrThrow(account)
|
||||
val localFolder = localStore.getFolder(command.folderId)
|
||||
localFolder.open()
|
||||
|
||||
val backend = messagingController.getBackend(account)
|
||||
|
||||
val uploadMessageId = command.uploadMessageId
|
||||
val localMessage = localFolder.getMessage(uploadMessageId)
|
||||
if (localMessage == null) {
|
||||
Timber.w("Couldn't find local copy of message to upload [ID: %d]", uploadMessageId)
|
||||
return
|
||||
} else if (!localMessage.uid.startsWith(K9.LOCAL_UID_PREFIX)) {
|
||||
Timber.i("Message [ID: %d] to be uploaded already has a server ID set. Skipping upload.", uploadMessageId)
|
||||
} else {
|
||||
uploadMessage(backend, account, localFolder, localMessage)
|
||||
}
|
||||
|
||||
deleteMessage(backend, account, localFolder, command.deleteMessageId)
|
||||
}
|
||||
|
||||
private fun uploadMessage(
|
||||
backend: Backend,
|
||||
account: Account,
|
||||
localFolder: LocalFolder,
|
||||
localMessage: LocalMessage
|
||||
) {
|
||||
val folderServerId = localFolder.serverId
|
||||
Timber.d("Uploading message [ID: %d] to remote folder '%s'", localMessage.databaseId, folderServerId)
|
||||
|
||||
val fetchProfile = FetchProfile().apply {
|
||||
add(FetchProfile.Item.BODY)
|
||||
}
|
||||
localFolder.fetch(listOf(localMessage), fetchProfile, null)
|
||||
|
||||
val messageServerId = backend.uploadMessage(folderServerId, localMessage)
|
||||
|
||||
if (messageServerId == null) {
|
||||
Timber.w(
|
||||
"Failed to get a server ID for the uploaded message. Removing local copy [ID: %d]",
|
||||
localMessage.databaseId
|
||||
)
|
||||
localMessage.destroy()
|
||||
} else {
|
||||
val oldUid = localMessage.uid
|
||||
|
||||
localMessage.uid = messageServerId
|
||||
localFolder.changeUid(localMessage)
|
||||
|
||||
for (listener in messagingController.listeners) {
|
||||
listener.messageUidChanged(account, localFolder.databaseId, oldUid, localMessage.uid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteMessage(backend: Backend, account: Account, localFolder: LocalFolder, messageId: Long) {
|
||||
val messageServerId = localFolder.getMessageUidById(messageId) ?: run {
|
||||
Timber.i("Couldn't find local copy of message [ID: %d] to be deleted. Skipping delete.", messageId)
|
||||
return
|
||||
}
|
||||
|
||||
val messageServerIds = listOf(messageServerId)
|
||||
val folderServerId = localFolder.serverId
|
||||
backend.deleteMessages(folderServerId, messageServerIds)
|
||||
|
||||
if (backend.supportsExpunge && account.expungePolicy == Expunge.EXPUNGE_IMMEDIATELY) {
|
||||
backend.expungeMessages(folderServerId, messageServerIds)
|
||||
}
|
||||
|
||||
messagingController.destroyPlaceholderMessages(localFolder, messageServerIds)
|
||||
}
|
||||
|
||||
private fun Message.toSaveMessageData(subject: String?): SaveMessageData {
|
||||
return saveMessageDataCreator.createSaveMessageData(this, MessageDownloadState.FULL, subject)
|
||||
}
|
||||
}
|
||||
36
app/core/src/main/java/com/fsck/k9/controller/KoinModule.kt
Normal file
36
app/core/src/main/java/com/fsck/k9/controller/KoinModule.kt
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package com.fsck.k9.controller
|
||||
|
||||
import android.content.Context
|
||||
import com.fsck.k9.Preferences
|
||||
import com.fsck.k9.backend.BackendManager
|
||||
import com.fsck.k9.mailstore.LocalStoreProvider
|
||||
import com.fsck.k9.mailstore.MessageStoreManager
|
||||
import com.fsck.k9.mailstore.SaveMessageDataCreator
|
||||
import com.fsck.k9.mailstore.SpecialLocalFoldersCreator
|
||||
import com.fsck.k9.notification.NotificationController
|
||||
import com.fsck.k9.notification.NotificationStrategy
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
|
||||
val controllerModule = module {
|
||||
single {
|
||||
MessagingController(
|
||||
get<Context>(),
|
||||
get<NotificationController>(),
|
||||
get<NotificationStrategy>(),
|
||||
get<LocalStoreProvider>(),
|
||||
get<BackendManager>(),
|
||||
get<Preferences>(),
|
||||
get<MessageStoreManager>(),
|
||||
get<SaveMessageDataCreator>(),
|
||||
get<SpecialLocalFoldersCreator>(),
|
||||
get(named("controllerExtensions"))
|
||||
)
|
||||
}
|
||||
single<MessageCountsProvider> {
|
||||
DefaultMessageCountsProvider(
|
||||
preferences = get(),
|
||||
messageStoreManager = get()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package com.fsck.k9.controller;
|
||||
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import java.util.Map.Entry;
|
||||
|
||||
import com.fsck.k9.Account;
|
||||
|
||||
|
||||
class MemorizingMessagingListener extends SimpleMessagingListener {
|
||||
Map<String, Memory> memories = new HashMap<>(31);
|
||||
|
||||
synchronized void removeAccount(Account account) {
|
||||
Iterator<Entry<String, Memory>> memIt = memories.entrySet().iterator();
|
||||
|
||||
while (memIt.hasNext()) {
|
||||
Entry<String, Memory> memoryEntry = memIt.next();
|
||||
|
||||
String uuidForMemory = memoryEntry.getValue().account.getUuid();
|
||||
|
||||
if (uuidForMemory.equals(account.getUuid())) {
|
||||
memIt.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
synchronized void refreshOther(MessagingListener other) {
|
||||
if (other != null) {
|
||||
|
||||
Memory syncStarted = null;
|
||||
|
||||
for (Memory memory : memories.values()) {
|
||||
|
||||
if (memory.syncingState != null) {
|
||||
switch (memory.syncingState) {
|
||||
case STARTED:
|
||||
syncStarted = memory;
|
||||
break;
|
||||
case FINISHED:
|
||||
other.synchronizeMailboxFinished(memory.account, memory.folderId);
|
||||
break;
|
||||
case FAILED:
|
||||
other.synchronizeMailboxFailed(memory.account, memory.folderId,
|
||||
memory.failureMessage);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Memory somethingStarted = null;
|
||||
if (syncStarted != null) {
|
||||
other.synchronizeMailboxStarted(syncStarted.account, syncStarted.folderId);
|
||||
somethingStarted = syncStarted;
|
||||
}
|
||||
if (somethingStarted != null && somethingStarted.folderTotal > 0) {
|
||||
other.synchronizeMailboxProgress(somethingStarted.account, somethingStarted.folderId,
|
||||
somethingStarted.folderCompleted, somethingStarted.folderTotal);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void synchronizeMailboxStarted(Account account, long folderId) {
|
||||
Memory memory = getMemory(account, folderId);
|
||||
memory.syncingState = MemorizingState.STARTED;
|
||||
memory.folderCompleted = 0;
|
||||
memory.folderTotal = 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void synchronizeMailboxFinished(Account account, long folderId) {
|
||||
Memory memory = getMemory(account, folderId);
|
||||
memory.syncingState = MemorizingState.FINISHED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void synchronizeMailboxFailed(Account account, long folderId,
|
||||
String message) {
|
||||
|
||||
Memory memory = getMemory(account, folderId);
|
||||
memory.syncingState = MemorizingState.FAILED;
|
||||
memory.failureMessage = message;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void synchronizeMailboxProgress(Account account, long folderId, int completed,
|
||||
int total) {
|
||||
Memory memory = getMemory(account, folderId);
|
||||
memory.folderCompleted = completed;
|
||||
memory.folderTotal = total;
|
||||
}
|
||||
|
||||
private Memory getMemory(Account account, long folderId) {
|
||||
Memory memory = memories.get(getMemoryKey(account, folderId));
|
||||
if (memory == null) {
|
||||
memory = new Memory(account, folderId);
|
||||
memories.put(getMemoryKey(memory.account, memory.folderId), memory);
|
||||
}
|
||||
return memory;
|
||||
}
|
||||
|
||||
private static String getMemoryKey(Account account, long folderId) {
|
||||
return account.getUuid() + ":" + folderId;
|
||||
}
|
||||
|
||||
private enum MemorizingState { STARTED, FINISHED, FAILED }
|
||||
|
||||
private static class Memory {
|
||||
Account account;
|
||||
long folderId;
|
||||
MemorizingState syncingState = null;
|
||||
String failureMessage = null;
|
||||
|
||||
int folderCompleted = 0;
|
||||
int folderTotal = 0;
|
||||
|
||||
Memory(Account account, long folderId) {
|
||||
this.account = account;
|
||||
this.folderId = folderId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package com.fsck.k9.controller
|
||||
|
||||
import com.fsck.k9.Account
|
||||
import com.fsck.k9.Preferences
|
||||
import com.fsck.k9.mailstore.MessageStoreManager
|
||||
import com.fsck.k9.search.ConditionsTreeNode
|
||||
import com.fsck.k9.search.LocalSearch
|
||||
import com.fsck.k9.search.SearchAccount
|
||||
import com.fsck.k9.search.excludeSpecialFolders
|
||||
import com.fsck.k9.search.getAccounts
|
||||
import com.fsck.k9.search.limitToDisplayableFolders
|
||||
import timber.log.Timber
|
||||
|
||||
interface MessageCountsProvider {
|
||||
fun getMessageCounts(account: Account): MessageCounts
|
||||
fun getMessageCounts(searchAccount: SearchAccount): MessageCounts
|
||||
fun getUnreadMessageCount(account: Account, folderId: Long): Int
|
||||
}
|
||||
|
||||
data class MessageCounts(val unread: Int, val starred: Int)
|
||||
|
||||
internal class DefaultMessageCountsProvider(
|
||||
private val preferences: Preferences,
|
||||
private val messageStoreManager: MessageStoreManager
|
||||
) : MessageCountsProvider {
|
||||
override fun getMessageCounts(account: Account): MessageCounts {
|
||||
val search = LocalSearch().apply {
|
||||
excludeSpecialFolders(account)
|
||||
limitToDisplayableFolders(account)
|
||||
}
|
||||
|
||||
return getMessageCounts(account, search.conditions)
|
||||
}
|
||||
|
||||
override fun getMessageCounts(searchAccount: SearchAccount): MessageCounts {
|
||||
val search = searchAccount.relatedSearch
|
||||
val accounts = search.getAccounts(preferences)
|
||||
|
||||
var unreadCount = 0
|
||||
var starredCount = 0
|
||||
for (account in accounts) {
|
||||
val accountMessageCount = getMessageCounts(account, search.conditions)
|
||||
unreadCount += accountMessageCount.unread
|
||||
starredCount += accountMessageCount.starred
|
||||
}
|
||||
|
||||
return MessageCounts(unreadCount, starredCount)
|
||||
}
|
||||
|
||||
override fun getUnreadMessageCount(account: Account, folderId: Long): Int {
|
||||
return try {
|
||||
val messageStore = messageStoreManager.getMessageStore(account)
|
||||
return if (folderId == account.outboxFolderId) {
|
||||
messageStore.getMessageCount(folderId)
|
||||
} else {
|
||||
messageStore.getUnreadMessageCount(folderId)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Unable to getUnreadMessageCount for account: %s, folder: %d", account, folderId)
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMessageCounts(account: Account, conditions: ConditionsTreeNode?): MessageCounts {
|
||||
return try {
|
||||
val messageStore = messageStoreManager.getMessageStore(account)
|
||||
return MessageCounts(
|
||||
unread = messageStore.getUnreadMessageCount(conditions),
|
||||
starred = messageStore.getStarredMessageCount(conditions)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Unable to getMessageCounts for account: %s", account)
|
||||
MessageCounts(unread = 0, starred = 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package com.fsck.k9.controller
|
||||
|
||||
import com.fsck.k9.mail.filter.Base64
|
||||
import java.util.StringTokenizer
|
||||
|
||||
data class MessageReference(
|
||||
val accountUuid: String,
|
||||
val folderId: Long,
|
||||
val uid: String
|
||||
) {
|
||||
fun toIdentityString(): String {
|
||||
return buildString {
|
||||
append(IDENTITY_VERSION_2)
|
||||
append(IDENTITY_SEPARATOR)
|
||||
append(Base64.encode(accountUuid))
|
||||
append(IDENTITY_SEPARATOR)
|
||||
append(Base64.encode(folderId.toString()))
|
||||
append(IDENTITY_SEPARATOR)
|
||||
append(Base64.encode(uid))
|
||||
}
|
||||
}
|
||||
|
||||
fun equals(accountUuid: String, folderId: Long, uid: String): Boolean {
|
||||
return this.accountUuid == accountUuid && this.folderId == folderId && this.uid == uid
|
||||
}
|
||||
|
||||
fun withModifiedUid(newUid: String): MessageReference {
|
||||
return copy(uid = newUid)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val IDENTITY_VERSION_2 = '#'
|
||||
private const val IDENTITY_SEPARATOR = ":"
|
||||
|
||||
@JvmStatic
|
||||
fun parse(identity: String?): MessageReference? {
|
||||
if (identity == null || identity.isEmpty() || identity[0] != IDENTITY_VERSION_2) {
|
||||
return null
|
||||
}
|
||||
|
||||
val tokens = StringTokenizer(identity.substring(2), IDENTITY_SEPARATOR, false)
|
||||
if (tokens.countTokens() < 3) {
|
||||
return null
|
||||
}
|
||||
|
||||
val accountUuid = Base64.decode(tokens.nextToken())
|
||||
val folderId = Base64.decode(tokens.nextToken()).toLong()
|
||||
val uid = Base64.decode(tokens.nextToken())
|
||||
return MessageReference(accountUuid, folderId, uid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package com.fsck.k9.controller;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import timber.log.Timber;
|
||||
|
||||
|
||||
public class MessageReferenceHelper {
|
||||
public static List<MessageReference> toMessageReferenceList(List<String> messageReferenceStrings) {
|
||||
List<MessageReference> messageReferences = new ArrayList<>(messageReferenceStrings.size());
|
||||
for (String messageReferenceString : messageReferenceStrings) {
|
||||
MessageReference messageReference = MessageReference.parse(messageReferenceString);
|
||||
if (messageReference != null) {
|
||||
messageReferences.add(messageReference);
|
||||
} else {
|
||||
Timber.w("Invalid message reference: %s", messageReferenceString);
|
||||
}
|
||||
}
|
||||
|
||||
return messageReferences;
|
||||
}
|
||||
|
||||
public static ArrayList<String> toMessageReferenceStringList(List<MessageReference> messageReferences) {
|
||||
ArrayList<String> messageReferenceStrings = new ArrayList<>(messageReferences.size());
|
||||
for (MessageReference messageReference : messageReferences) {
|
||||
String messageReferenceString = messageReference.toIdentityString();
|
||||
messageReferenceStrings.add(messageReferenceString);
|
||||
}
|
||||
|
||||
return messageReferenceStrings;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,269 @@
|
|||
package com.fsck.k9.controller;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fsck.k9.Account;
|
||||
import com.fsck.k9.mail.Flag;
|
||||
import com.fsck.k9.mail.MessagingException;
|
||||
|
||||
import static com.fsck.k9.controller.Preconditions.requireNotNull;
|
||||
import static com.fsck.k9.controller.Preconditions.requireValidUids;
|
||||
|
||||
|
||||
public class MessagingControllerCommands {
|
||||
static final String COMMAND_APPEND = "append";
|
||||
static final String COMMAND_REPLACE = "replace";
|
||||
static final String COMMAND_MARK_ALL_AS_READ = "mark_all_as_read";
|
||||
static final String COMMAND_SET_FLAG = "set_flag";
|
||||
static final String COMMAND_DELETE = "delete";
|
||||
static final String COMMAND_EXPUNGE = "expunge";
|
||||
static final String COMMAND_MOVE_OR_COPY = "move_or_copy";
|
||||
static final String COMMAND_MOVE_AND_MARK_AS_READ = "move_and_mark_as_read";
|
||||
static final String COMMAND_EMPTY_TRASH = "empty_trash";
|
||||
|
||||
public abstract static class PendingCommand {
|
||||
public long databaseId;
|
||||
|
||||
|
||||
PendingCommand() { }
|
||||
|
||||
public abstract String getCommandName();
|
||||
public abstract void execute(MessagingController controller, Account account) throws MessagingException;
|
||||
}
|
||||
|
||||
public static class PendingMoveOrCopy extends PendingCommand {
|
||||
public final long srcFolderId;
|
||||
public final long destFolderId;
|
||||
public final boolean isCopy;
|
||||
public final List<String> uids;
|
||||
public final Map<String, String> newUidMap;
|
||||
|
||||
|
||||
public static PendingMoveOrCopy create(long srcFolderId, long destFolderId, boolean isCopy,
|
||||
Map<String, String> uidMap) {
|
||||
requireValidUids(uidMap);
|
||||
return new PendingMoveOrCopy(srcFolderId, destFolderId, isCopy, null, uidMap);
|
||||
}
|
||||
|
||||
private PendingMoveOrCopy(long srcFolderId, long destFolderId, boolean isCopy, List<String> uids,
|
||||
Map<String, String> newUidMap) {
|
||||
this.srcFolderId = srcFolderId;
|
||||
this.destFolderId = destFolderId;
|
||||
this.isCopy = isCopy;
|
||||
this.uids = uids;
|
||||
this.newUidMap = newUidMap;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCommandName() {
|
||||
return COMMAND_MOVE_OR_COPY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(MessagingController controller, Account account) throws MessagingException {
|
||||
controller.processPendingMoveOrCopy(this, account);
|
||||
}
|
||||
}
|
||||
|
||||
public static class PendingMoveAndMarkAsRead extends PendingCommand {
|
||||
public final long srcFolderId;
|
||||
public final long destFolderId;
|
||||
public final Map<String, String> newUidMap;
|
||||
|
||||
|
||||
public static PendingMoveAndMarkAsRead create(long srcFolderId, long destFolderId, Map<String, String> uidMap) {
|
||||
requireValidUids(uidMap);
|
||||
return new PendingMoveAndMarkAsRead(srcFolderId, destFolderId, uidMap);
|
||||
}
|
||||
|
||||
private PendingMoveAndMarkAsRead(long srcFolderId, long destFolderId, Map<String, String> newUidMap) {
|
||||
this.srcFolderId = srcFolderId;
|
||||
this.destFolderId = destFolderId;
|
||||
this.newUidMap = newUidMap;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCommandName() {
|
||||
return COMMAND_MOVE_AND_MARK_AS_READ;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(MessagingController controller, Account account) throws MessagingException {
|
||||
controller.processPendingMoveAndRead(this, account);
|
||||
}
|
||||
}
|
||||
|
||||
public static class PendingEmptyTrash extends PendingCommand {
|
||||
public static PendingEmptyTrash create() {
|
||||
return new PendingEmptyTrash();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCommandName() {
|
||||
return COMMAND_EMPTY_TRASH;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(MessagingController controller, Account account) throws MessagingException {
|
||||
controller.processPendingEmptyTrash(account);
|
||||
}
|
||||
}
|
||||
|
||||
public static class PendingSetFlag extends PendingCommand {
|
||||
public final long folderId;
|
||||
public final boolean newState;
|
||||
public final Flag flag;
|
||||
public final List<String> uids;
|
||||
|
||||
|
||||
public static PendingSetFlag create(long folderId, boolean newState, Flag flag, List<String> uids) {
|
||||
requireNotNull(flag);
|
||||
requireValidUids(uids);
|
||||
return new PendingSetFlag(folderId, newState, flag, uids);
|
||||
}
|
||||
|
||||
private PendingSetFlag(long folderId, boolean newState, Flag flag, List<String> uids) {
|
||||
this.folderId = folderId;
|
||||
this.newState = newState;
|
||||
this.flag = flag;
|
||||
this.uids = uids;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCommandName() {
|
||||
return COMMAND_SET_FLAG;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(MessagingController controller, Account account) throws MessagingException {
|
||||
controller.processPendingSetFlag(this, account);
|
||||
}
|
||||
}
|
||||
|
||||
public static class PendingAppend extends PendingCommand {
|
||||
public final long folderId;
|
||||
public final String uid;
|
||||
|
||||
|
||||
public static PendingAppend create(long folderId, String uid) {
|
||||
requireNotNull(uid);
|
||||
return new PendingAppend(folderId, uid);
|
||||
}
|
||||
|
||||
private PendingAppend(long folderId, String uid) {
|
||||
this.folderId = folderId;
|
||||
this.uid = uid;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCommandName() {
|
||||
return COMMAND_APPEND;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(MessagingController controller, Account account) throws MessagingException {
|
||||
controller.processPendingAppend(this, account);
|
||||
}
|
||||
}
|
||||
|
||||
public static class PendingReplace extends PendingCommand {
|
||||
public final long folderId;
|
||||
public final long uploadMessageId;
|
||||
public final long deleteMessageId;
|
||||
|
||||
|
||||
public static PendingReplace create(long folderId, long uploadMessageId, long deleteMessageId) {
|
||||
return new PendingReplace(folderId, uploadMessageId, deleteMessageId);
|
||||
}
|
||||
|
||||
private PendingReplace(long folderId, long uploadMessageId, long deleteMessageId) {
|
||||
this.folderId = folderId;
|
||||
this.uploadMessageId = uploadMessageId;
|
||||
this.deleteMessageId = deleteMessageId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCommandName() {
|
||||
return COMMAND_REPLACE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(MessagingController controller, Account account) throws MessagingException {
|
||||
controller.processPendingReplace(this, account);
|
||||
}
|
||||
}
|
||||
|
||||
public static class PendingMarkAllAsRead extends PendingCommand {
|
||||
public final long folderId;
|
||||
|
||||
|
||||
public static PendingMarkAllAsRead create(long folderId) {
|
||||
return new PendingMarkAllAsRead(folderId);
|
||||
}
|
||||
|
||||
private PendingMarkAllAsRead(long folderId) {
|
||||
this.folderId = folderId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCommandName() {
|
||||
return COMMAND_MARK_ALL_AS_READ;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(MessagingController controller, Account account) throws MessagingException {
|
||||
controller.processPendingMarkAllAsRead(this, account);
|
||||
}
|
||||
}
|
||||
|
||||
public static class PendingDelete extends PendingCommand {
|
||||
public final long folderId;
|
||||
public final List<String> uids;
|
||||
|
||||
|
||||
public static PendingDelete create(long folderId, List<String> uids) {
|
||||
requireValidUids(uids);
|
||||
return new PendingDelete(folderId, uids);
|
||||
}
|
||||
|
||||
private PendingDelete(long folderId, List<String> uids) {
|
||||
this.folderId = folderId;
|
||||
this.uids = uids;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCommandName() {
|
||||
return COMMAND_DELETE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(MessagingController controller, Account account) throws MessagingException {
|
||||
controller.processPendingDelete(this, account);
|
||||
}
|
||||
}
|
||||
|
||||
public static class PendingExpunge extends PendingCommand {
|
||||
public final long folderId;
|
||||
|
||||
|
||||
public static PendingExpunge create(long folderId) {
|
||||
return new PendingExpunge(folderId);
|
||||
}
|
||||
|
||||
private PendingExpunge(long folderId) {
|
||||
this.folderId = folderId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCommandName() {
|
||||
return COMMAND_EXPUNGE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(MessagingController controller, Account account) throws MessagingException {
|
||||
controller.processPendingExpunge(this, account);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
|
||||
package com.fsck.k9.controller;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.fsck.k9.Account;
|
||||
import com.fsck.k9.mail.Message;
|
||||
import com.fsck.k9.mail.Part;
|
||||
import com.fsck.k9.mailstore.LocalMessage;
|
||||
|
||||
|
||||
public interface MessagingListener {
|
||||
void synchronizeMailboxStarted(Account account, long folderId);
|
||||
void synchronizeMailboxHeadersStarted(Account account, String folderServerId);
|
||||
void synchronizeMailboxHeadersProgress(Account account, String folderServerId, int completed, int total);
|
||||
void synchronizeMailboxHeadersFinished(Account account, String folderServerId, int totalMessagesInMailbox,
|
||||
int numNewMessages);
|
||||
void synchronizeMailboxProgress(Account account, long folderId, int completed, int total);
|
||||
void synchronizeMailboxNewMessage(Account account, String folderServerId, Message message);
|
||||
void synchronizeMailboxRemovedMessage(Account account, String folderServerId, String messageServerId);
|
||||
void synchronizeMailboxFinished(Account account, long folderId);
|
||||
void synchronizeMailboxFailed(Account account, long folderId, String message);
|
||||
|
||||
void loadMessageRemoteFinished(Account account, long folderId, String uid);
|
||||
void loadMessageRemoteFailed(Account account, long folderId, String uid, Throwable t);
|
||||
|
||||
void checkMailStarted(Context context, Account account);
|
||||
void checkMailFinished(Context context, Account account);
|
||||
|
||||
void folderStatusChanged(Account account, long folderId);
|
||||
|
||||
void messageUidChanged(Account account, long folderId, String oldUid, String newUid);
|
||||
|
||||
void loadAttachmentFinished(Account account, Message message, Part part);
|
||||
void loadAttachmentFailed(Account account, Message message, Part part, String reason);
|
||||
|
||||
void remoteSearchStarted(long folderId);
|
||||
void remoteSearchServerQueryComplete(long folderId, int numResults, int maxResults);
|
||||
void remoteSearchFinished(long folderId, int numResults, int maxResults, List<String> extraResults);
|
||||
void remoteSearchFailed(String folderServerId, String err);
|
||||
|
||||
void enableProgressIndicator(boolean enable);
|
||||
|
||||
void updateProgress(int progress);
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
package com.fsck.k9.controller
|
||||
|
||||
import com.fsck.k9.Account
|
||||
import com.fsck.k9.Preferences
|
||||
import com.fsck.k9.mailstore.MessageStoreManager
|
||||
import com.fsck.k9.notification.NotificationController
|
||||
import com.fsck.k9.search.LocalSearch
|
||||
import com.fsck.k9.search.isNewMessages
|
||||
import com.fsck.k9.search.isSingleFolder
|
||||
import com.fsck.k9.search.isUnifiedInbox
|
||||
|
||||
internal class NotificationOperations(
|
||||
private val notificationController: NotificationController,
|
||||
private val preferences: Preferences,
|
||||
private val messageStoreManager: MessageStoreManager
|
||||
) {
|
||||
fun clearNotifications(search: LocalSearch) {
|
||||
if (search.isUnifiedInbox) {
|
||||
clearUnifiedInboxNotifications()
|
||||
} else if (search.isNewMessages) {
|
||||
clearAllNotifications()
|
||||
} else if (search.isSingleFolder) {
|
||||
val account = search.firstAccount() ?: return
|
||||
val folderId = search.folderIds.first()
|
||||
clearNotifications(account, folderId)
|
||||
} else {
|
||||
// TODO: Remove notifications when updating the message list. That way we can easily remove only
|
||||
// notifications for messages that are currently displayed in the list.
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearUnifiedInboxNotifications() {
|
||||
for (account in preferences.accounts) {
|
||||
val messageStore = messageStoreManager.getMessageStore(account)
|
||||
|
||||
val folderIds = messageStore.getFolders(excludeLocalOnly = true) { folderDetails ->
|
||||
if (folderDetails.isIntegrate) folderDetails.id else null
|
||||
}.filterNotNull().toSet()
|
||||
|
||||
if (folderIds.isNotEmpty()) {
|
||||
notificationController.clearNewMailNotifications(account) { messageReferences ->
|
||||
messageReferences.filter { messageReference -> messageReference.folderId in folderIds }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearAllNotifications() {
|
||||
for (account in preferences.accounts) {
|
||||
notificationController.clearNewMailNotifications(account, clearNewMessageState = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearNotifications(account: Account, folderId: Long) {
|
||||
notificationController.clearNewMailNotifications(account) { messageReferences ->
|
||||
messageReferences.filter { messageReference -> messageReference.folderId == folderId }
|
||||
}
|
||||
}
|
||||
|
||||
private fun LocalSearch.firstAccount(): Account? {
|
||||
return preferences.getAccount(accountUuids.first())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.fsck.k9.controller
|
||||
|
||||
class NotificationState {
|
||||
@get:JvmName("wasNotified")
|
||||
var wasNotified: Boolean = false
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package com.fsck.k9.controller;
|
||||
|
||||
|
||||
import java.io.IOError;
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fsck.k9.controller.MessagingControllerCommands.PendingAppend;
|
||||
import com.fsck.k9.controller.MessagingControllerCommands.PendingCommand;
|
||||
import com.fsck.k9.controller.MessagingControllerCommands.PendingDelete;
|
||||
import com.fsck.k9.controller.MessagingControllerCommands.PendingEmptyTrash;
|
||||
import com.fsck.k9.controller.MessagingControllerCommands.PendingExpunge;
|
||||
import com.fsck.k9.controller.MessagingControllerCommands.PendingMarkAllAsRead;
|
||||
import com.fsck.k9.controller.MessagingControllerCommands.PendingMoveAndMarkAsRead;
|
||||
import com.fsck.k9.controller.MessagingControllerCommands.PendingMoveOrCopy;
|
||||
import com.fsck.k9.controller.MessagingControllerCommands.PendingReplace;
|
||||
import com.fsck.k9.controller.MessagingControllerCommands.PendingSetFlag;
|
||||
import com.squareup.moshi.JsonAdapter;
|
||||
import com.squareup.moshi.Moshi;
|
||||
|
||||
|
||||
public class PendingCommandSerializer {
|
||||
private static final PendingCommandSerializer INSTANCE = new PendingCommandSerializer();
|
||||
|
||||
|
||||
private final Map<String, JsonAdapter<? extends PendingCommand>> adapters;
|
||||
|
||||
|
||||
private PendingCommandSerializer() {
|
||||
Moshi moshi = new Moshi.Builder().build();
|
||||
HashMap<String, JsonAdapter<? extends PendingCommand>> adapters = new HashMap<>();
|
||||
|
||||
adapters.put(MessagingControllerCommands.COMMAND_MOVE_OR_COPY, moshi.adapter(PendingMoveOrCopy.class));
|
||||
adapters.put(MessagingControllerCommands.COMMAND_MOVE_AND_MARK_AS_READ,
|
||||
moshi.adapter(PendingMoveAndMarkAsRead.class));
|
||||
adapters.put(MessagingControllerCommands.COMMAND_APPEND, moshi.adapter(PendingAppend.class));
|
||||
adapters.put(MessagingControllerCommands.COMMAND_REPLACE, moshi.adapter(PendingReplace.class));
|
||||
adapters.put(MessagingControllerCommands.COMMAND_EMPTY_TRASH, moshi.adapter(PendingEmptyTrash.class));
|
||||
adapters.put(MessagingControllerCommands.COMMAND_EXPUNGE, moshi.adapter(PendingExpunge.class));
|
||||
adapters.put(MessagingControllerCommands.COMMAND_MARK_ALL_AS_READ, moshi.adapter(PendingMarkAllAsRead.class));
|
||||
adapters.put(MessagingControllerCommands.COMMAND_SET_FLAG, moshi.adapter(PendingSetFlag.class));
|
||||
adapters.put(MessagingControllerCommands.COMMAND_DELETE, moshi.adapter(PendingDelete.class));
|
||||
|
||||
this.adapters = Collections.unmodifiableMap(adapters);
|
||||
}
|
||||
|
||||
|
||||
public static PendingCommandSerializer getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
|
||||
public <T extends PendingCommand> String serialize(T command) {
|
||||
// noinspection unchecked, we know the map has correctly matching adapters
|
||||
JsonAdapter<T> adapter = (JsonAdapter<T>) adapters.get(command.getCommandName());
|
||||
if (adapter == null) {
|
||||
throw new IllegalArgumentException("Unsupported pending command type!");
|
||||
}
|
||||
return adapter.toJson(command);
|
||||
}
|
||||
|
||||
public PendingCommand unserialize(long databaseId, String commandName, String data) {
|
||||
JsonAdapter<? extends PendingCommand> adapter = adapters.get(commandName);
|
||||
if (adapter == null) {
|
||||
throw new IllegalArgumentException("Unsupported pending command type!");
|
||||
}
|
||||
try {
|
||||
PendingCommand command = adapter.fromJson(data);
|
||||
command.databaseId = databaseId;
|
||||
return command;
|
||||
} catch (IOException e) {
|
||||
throw new IOError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
@file:JvmName("Preconditions")
|
||||
|
||||
package com.fsck.k9.controller
|
||||
|
||||
import com.fsck.k9.K9
|
||||
|
||||
fun <T : Any> requireNotNull(value: T?) {
|
||||
kotlin.requireNotNull(value)
|
||||
}
|
||||
|
||||
fun requireValidUids(uidMap: Map<String?, String?>?) {
|
||||
kotlin.requireNotNull(uidMap)
|
||||
for ((sourceUid, destinationUid) in uidMap) {
|
||||
requireNotLocalUid(sourceUid)
|
||||
kotlin.requireNotNull(destinationUid)
|
||||
}
|
||||
}
|
||||
|
||||
fun requireValidUids(uids: List<String?>?) {
|
||||
kotlin.requireNotNull(uids)
|
||||
for (uid in uids) {
|
||||
requireNotLocalUid(uid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireNotLocalUid(uid: String?) {
|
||||
kotlin.requireNotNull(uid)
|
||||
require(!uid.startsWith(K9.LOCAL_UID_PREFIX)) { "Local UID found: $uid" }
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.fsck.k9.controller;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.Timer;
|
||||
import java.util.TimerTask;
|
||||
|
||||
import com.fsck.k9.mail.DefaultBodyFactory;
|
||||
import org.apache.commons.io.output.CountingOutputStream;
|
||||
|
||||
|
||||
class ProgressBodyFactory extends DefaultBodyFactory {
|
||||
private final ProgressListener progressListener;
|
||||
|
||||
|
||||
ProgressBodyFactory(ProgressListener progressListener) {
|
||||
this.progressListener = progressListener;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void copyData(InputStream inputStream, OutputStream outputStream) throws IOException {
|
||||
Timer timer = new Timer();
|
||||
try (CountingOutputStream countingOutputStream = new CountingOutputStream(outputStream)) {
|
||||
timer.scheduleAtFixedRate(new TimerTask() {
|
||||
@Override
|
||||
public void run() {
|
||||
progressListener.updateProgress(countingOutputStream.getCount());
|
||||
}
|
||||
}, 0, 50);
|
||||
|
||||
super.copyData(inputStream, countingOutputStream);
|
||||
} finally {
|
||||
timer.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
interface ProgressListener {
|
||||
void updateProgress(int progress);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
|
||||
package com.fsck.k9.controller;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.fsck.k9.Account;
|
||||
import com.fsck.k9.mail.Message;
|
||||
import com.fsck.k9.mail.Part;
|
||||
|
||||
|
||||
public abstract class SimpleMessagingListener implements MessagingListener {
|
||||
@Override
|
||||
public void synchronizeMailboxStarted(Account account, long folderId) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void synchronizeMailboxHeadersStarted(Account account, String folderServerId) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void synchronizeMailboxHeadersProgress(Account account, String folderServerId, int completed, int total) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void synchronizeMailboxHeadersFinished(Account account, String folderServerId, int totalMessagesInMailbox,
|
||||
int numNewMessages) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void synchronizeMailboxProgress(Account account, long folderId, int completed, int total) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void synchronizeMailboxNewMessage(Account account, String folderServerId, Message message) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void synchronizeMailboxRemovedMessage(Account account, String folderServerId, String messageServerId) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void synchronizeMailboxFinished(Account account, long folderId) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void synchronizeMailboxFailed(Account account, long folderId, String message) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadMessageRemoteFinished(Account account, long folderId, String uid) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadMessageRemoteFailed(Account account, long folderId, String uid, Throwable t) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkMailStarted(Context context, Account account) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkMailFinished(Context context, Account account) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void folderStatusChanged(Account account, long folderId) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void messageUidChanged(Account account, long folderId, String oldUid, String newUid) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadAttachmentFinished(Account account, Message message, Part part) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadAttachmentFailed(Account account, Message message, Part part, String reason) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remoteSearchStarted(long folderId) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remoteSearchServerQueryComplete(long folderId, int numResults, int maxResults) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remoteSearchFinished(long folderId, int numResults, int maxResults, List<String> extraResults) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remoteSearchFailed(String folderServerId, String err) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void enableProgressIndicator(boolean enable) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateProgress(int progress) {
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package com.fsck.k9.controller;
|
||||
|
||||
|
||||
import java.util.Comparator;
|
||||
|
||||
import com.fsck.k9.mail.Message;
|
||||
|
||||
|
||||
public class UidReverseComparator implements Comparator<Message> {
|
||||
@Override
|
||||
public int compare(Message messageLeft, Message messageRight) {
|
||||
Long uidLeft = getUidForMessage(messageLeft);
|
||||
Long uidRight = getUidForMessage(messageRight);
|
||||
|
||||
if (uidLeft == null && uidRight == null) {
|
||||
return 0;
|
||||
} else if (uidLeft == null) {
|
||||
return 1;
|
||||
} else if (uidRight == null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// reverse order
|
||||
return uidRight.compareTo(uidLeft);
|
||||
}
|
||||
|
||||
private Long getUidForMessage(Message message) {
|
||||
try {
|
||||
return Long.parseLong(message.getUid());
|
||||
} catch (NullPointerException | NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
package com.fsck.k9.controller.push
|
||||
|
||||
import com.fsck.k9.Account
|
||||
import com.fsck.k9.Account.FolderMode
|
||||
import com.fsck.k9.Preferences
|
||||
import com.fsck.k9.backend.BackendManager
|
||||
import com.fsck.k9.backend.api.BackendPusher
|
||||
import com.fsck.k9.backend.api.BackendPusherCallback
|
||||
import com.fsck.k9.controller.MessagingController
|
||||
import com.fsck.k9.mailstore.FolderRepository
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
internal class AccountPushController(
|
||||
private val backendManager: BackendManager,
|
||||
private val messagingController: MessagingController,
|
||||
private val preferences: Preferences,
|
||||
private val folderRepository: FolderRepository,
|
||||
backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
private val account: Account
|
||||
) {
|
||||
private val coroutineScope = CoroutineScope(backgroundDispatcher)
|
||||
|
||||
@Volatile
|
||||
private var backendPusher: BackendPusher? = null
|
||||
|
||||
private val backendPusherCallback = object : BackendPusherCallback {
|
||||
override fun onPushEvent(folderServerId: String) {
|
||||
syncFolders(folderServerId)
|
||||
}
|
||||
|
||||
override fun onPushError(exception: Exception) {
|
||||
messagingController.handleException(account, exception)
|
||||
}
|
||||
|
||||
override fun onPushNotSupported() {
|
||||
Timber.v("AccountPushController(%s) - Push not supported. Disabling Push for account.", account.uuid)
|
||||
disablePush()
|
||||
}
|
||||
}
|
||||
|
||||
fun start() {
|
||||
Timber.v("AccountPushController(%s).start()", account.uuid)
|
||||
startBackendPusher()
|
||||
startListeningForPushFolders()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
Timber.v("AccountPushController(%s).stop()", account.uuid)
|
||||
stopListeningForPushFolders()
|
||||
stopBackendPusher()
|
||||
}
|
||||
|
||||
fun reconnect() {
|
||||
Timber.v("AccountPushController(%s).reconnect()", account.uuid)
|
||||
backendPusher?.reconnect()
|
||||
}
|
||||
|
||||
private fun startBackendPusher() {
|
||||
val backend = backendManager.getBackend(account)
|
||||
backendPusher = backend.createPusher(backendPusherCallback).also { backendPusher ->
|
||||
backendPusher.start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopBackendPusher() {
|
||||
backendPusher?.stop()
|
||||
backendPusher = null
|
||||
}
|
||||
|
||||
private fun startListeningForPushFolders() {
|
||||
coroutineScope.launch {
|
||||
folderRepository.getPushFoldersFlow(account).collect { remoteFolders ->
|
||||
val folderServerIds = remoteFolders.map { it.serverId }
|
||||
updatePushFolders(folderServerIds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopListeningForPushFolders() {
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
|
||||
private fun updatePushFolders(folderServerIds: List<String>) {
|
||||
Timber.v("AccountPushController(%s).updatePushFolders(): %s", account.uuid, folderServerIds)
|
||||
|
||||
backendPusher?.updateFolders(folderServerIds)
|
||||
}
|
||||
|
||||
private fun syncFolders(folderServerId: String) {
|
||||
messagingController.synchronizeMailboxBlocking(account, folderServerId)
|
||||
}
|
||||
|
||||
private fun disablePush() {
|
||||
account.folderPushMode = FolderMode.NONE
|
||||
preferences.saveAccount(account)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.fsck.k9.controller.push
|
||||
|
||||
import com.fsck.k9.Account
|
||||
import com.fsck.k9.Preferences
|
||||
import com.fsck.k9.backend.BackendManager
|
||||
import com.fsck.k9.controller.MessagingController
|
||||
import com.fsck.k9.mailstore.FolderRepository
|
||||
|
||||
internal class AccountPushControllerFactory(
|
||||
private val backendManager: BackendManager,
|
||||
private val messagingController: MessagingController,
|
||||
private val folderRepository: FolderRepository,
|
||||
private val preferences: Preferences
|
||||
) {
|
||||
fun create(account: Account): AccountPushController {
|
||||
return AccountPushController(
|
||||
backendManager,
|
||||
messagingController,
|
||||
preferences,
|
||||
folderRepository,
|
||||
account = account
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package com.fsck.k9.controller.push
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import com.fsck.k9.K9
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Listen for changes to the system's auto sync setting.
|
||||
*/
|
||||
internal class AutoSyncManager(private val context: Context) {
|
||||
val isAutoSyncDisabled: Boolean
|
||||
get() = respectSystemAutoSync && !ContentResolver.getMasterSyncAutomatically()
|
||||
|
||||
val respectSystemAutoSync: Boolean
|
||||
get() = K9.backgroundOps == K9.BACKGROUND_OPS.WHEN_CHECKED_AUTO_SYNC
|
||||
|
||||
private var isRegistered = false
|
||||
private var listener: AutoSyncListener? = null
|
||||
|
||||
private val intentFilter = IntentFilter().apply {
|
||||
addAction("com.android.sync.SYNC_CONN_STATUS_CHANGED")
|
||||
}
|
||||
|
||||
private val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
val listener = synchronized(this@AutoSyncManager) { listener }
|
||||
listener?.onAutoSyncChanged()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun registerListener(listener: AutoSyncListener) {
|
||||
if (!isRegistered) {
|
||||
Timber.v("Registering auto sync listener")
|
||||
isRegistered = true
|
||||
this.listener = listener
|
||||
context.registerReceiver(receiver, intentFilter)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun unregisterListener() {
|
||||
if (isRegistered) {
|
||||
Timber.v("Unregistering auto sync listener")
|
||||
isRegistered = false
|
||||
context.unregisterReceiver(receiver)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun interface AutoSyncListener {
|
||||
fun onAutoSyncChanged()
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package com.fsck.k9.controller.push
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||
import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
import android.content.pm.PackageManager.DONT_KILL_APP
|
||||
import java.lang.Exception
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import timber.log.Timber
|
||||
|
||||
class BootCompleteReceiver : BroadcastReceiver(), KoinComponent {
|
||||
private val pushController: PushController by inject()
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
Timber.v("BootCompleteReceiver.onReceive()")
|
||||
|
||||
pushController.init()
|
||||
}
|
||||
}
|
||||
|
||||
class BootCompleteManager(context: Context) {
|
||||
private val packageManager = context.packageManager
|
||||
private val componentName = ComponentName(context, BootCompleteReceiver::class.java)
|
||||
|
||||
fun enableReceiver() {
|
||||
Timber.v("Enable BootCompleteReceiver")
|
||||
try {
|
||||
packageManager.setComponentEnabledSetting(componentName, COMPONENT_ENABLED_STATE_ENABLED, DONT_KILL_APP)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error enabling BootCompleteReceiver")
|
||||
}
|
||||
}
|
||||
|
||||
fun disableReceiver() {
|
||||
Timber.v("Disable BootCompleteReceiver")
|
||||
try {
|
||||
packageManager.setComponentEnabledSetting(componentName, COMPONENT_ENABLED_STATE_DISABLED, DONT_KILL_APP)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error disabling BootCompleteReceiver")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package com.fsck.k9.controller.push
|
||||
|
||||
import org.koin.dsl.module
|
||||
|
||||
internal val controllerPushModule = module {
|
||||
single { PushServiceManager(context = get()) }
|
||||
single { BootCompleteManager(context = get()) }
|
||||
single { AutoSyncManager(context = get()) }
|
||||
single {
|
||||
AccountPushControllerFactory(
|
||||
backendManager = get(),
|
||||
messagingController = get(),
|
||||
folderRepository = get(),
|
||||
preferences = get()
|
||||
)
|
||||
}
|
||||
single {
|
||||
PushController(
|
||||
preferences = get(),
|
||||
generalSettingsManager = get(),
|
||||
backendManager = get(),
|
||||
pushServiceManager = get(),
|
||||
bootCompleteManager = get(),
|
||||
autoSyncManager = get(),
|
||||
pushNotificationManager = get(),
|
||||
connectivityManager = get(),
|
||||
accountPushControllerFactory = get()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
package com.fsck.k9.controller.push
|
||||
|
||||
import com.fsck.k9.Account
|
||||
import com.fsck.k9.Account.FolderMode
|
||||
import com.fsck.k9.Preferences
|
||||
import com.fsck.k9.backend.BackendManager
|
||||
import com.fsck.k9.network.ConnectivityChangeListener
|
||||
import com.fsck.k9.network.ConnectivityManager
|
||||
import com.fsck.k9.notification.PushNotificationManager
|
||||
import com.fsck.k9.notification.PushNotificationState
|
||||
import com.fsck.k9.notification.PushNotificationState.LISTENING
|
||||
import com.fsck.k9.notification.PushNotificationState.WAIT_BACKGROUND_SYNC
|
||||
import com.fsck.k9.notification.PushNotificationState.WAIT_NETWORK
|
||||
import com.fsck.k9.preferences.BackgroundSync
|
||||
import com.fsck.k9.preferences.GeneralSettingsManager
|
||||
import java.util.concurrent.Executors
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Starts and stops [AccountPushController]s as necessary. Manages the Push foreground service.
|
||||
*/
|
||||
class PushController internal constructor(
|
||||
private val preferences: Preferences,
|
||||
private val generalSettingsManager: GeneralSettingsManager,
|
||||
private val backendManager: BackendManager,
|
||||
private val pushServiceManager: PushServiceManager,
|
||||
private val bootCompleteManager: BootCompleteManager,
|
||||
private val autoSyncManager: AutoSyncManager,
|
||||
private val pushNotificationManager: PushNotificationManager,
|
||||
private val connectivityManager: ConnectivityManager,
|
||||
private val accountPushControllerFactory: AccountPushControllerFactory,
|
||||
private val coroutineScope: CoroutineScope = GlobalScope,
|
||||
private val coroutineDispatcher: CoroutineDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||
) {
|
||||
private val lock = Any()
|
||||
private var initializationStarted = false
|
||||
private val pushers = mutableMapOf<String, AccountPushController>()
|
||||
|
||||
private val autoSyncListener = AutoSyncListener(::onAutoSyncChanged)
|
||||
private val connectivityChangeListener = object : ConnectivityChangeListener {
|
||||
override fun onConnectivityChanged() = this@PushController.onConnectivityChanged()
|
||||
override fun onConnectivityLost() = this@PushController.onConnectivityLost()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize [PushController].
|
||||
*
|
||||
* Only call this method in situations where starting a foreground service is allowed.
|
||||
* See https://developer.android.com/about/versions/12/foreground-services
|
||||
*/
|
||||
fun init() {
|
||||
synchronized(lock) {
|
||||
if (initializationStarted) {
|
||||
return
|
||||
}
|
||||
initializationStarted = true
|
||||
}
|
||||
|
||||
coroutineScope.launch(coroutineDispatcher) {
|
||||
initInBackground()
|
||||
}
|
||||
}
|
||||
|
||||
fun disablePush() {
|
||||
Timber.v("PushController.disablePush()")
|
||||
|
||||
coroutineScope.launch(coroutineDispatcher) {
|
||||
for (account in preferences.accounts) {
|
||||
account.folderPushMode = FolderMode.NONE
|
||||
preferences.saveAccount(account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initInBackground() {
|
||||
Timber.v("PushController.initInBackground()")
|
||||
|
||||
preferences.addOnAccountsChangeListener(::onAccountsChanged)
|
||||
listenForBackgroundSyncChanges()
|
||||
backendManager.addListener(::onBackendChanged)
|
||||
|
||||
updatePushers()
|
||||
}
|
||||
|
||||
private fun listenForBackgroundSyncChanges() {
|
||||
generalSettingsManager.getSettingsFlow()
|
||||
.map { it.backgroundSync }
|
||||
.distinctUntilChanged()
|
||||
.onEach {
|
||||
launchUpdatePushers()
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
private fun onAccountsChanged() {
|
||||
launchUpdatePushers()
|
||||
}
|
||||
|
||||
private fun onAutoSyncChanged() {
|
||||
launchUpdatePushers()
|
||||
}
|
||||
|
||||
private fun onConnectivityChanged() {
|
||||
coroutineScope.launch(coroutineDispatcher) {
|
||||
synchronized(lock) {
|
||||
for (accountPushController in pushers.values) {
|
||||
accountPushController.reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
updatePushers()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onConnectivityLost() {
|
||||
launchUpdatePushers()
|
||||
}
|
||||
|
||||
private fun onBackendChanged(account: Account) {
|
||||
coroutineScope.launch(coroutineDispatcher) {
|
||||
val accountPushController = synchronized(lock) {
|
||||
pushers.remove(account.uuid)
|
||||
}
|
||||
|
||||
accountPushController?.stop()
|
||||
updatePushers()
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchUpdatePushers() {
|
||||
coroutineScope.launch(coroutineDispatcher) {
|
||||
updatePushers()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePushers() {
|
||||
Timber.v("PushController.updatePushers()")
|
||||
|
||||
val generalSettings = generalSettingsManager.getSettings()
|
||||
|
||||
val backgroundSyncDisabledViaSystem = autoSyncManager.isAutoSyncDisabled
|
||||
val backgroundSyncDisabledInApp = generalSettings.backgroundSync == BackgroundSync.NEVER
|
||||
val networkNotAvailable = !connectivityManager.isNetworkAvailable()
|
||||
val realPushAccounts = getPushAccounts()
|
||||
|
||||
val pushAccounts = if (backgroundSyncDisabledViaSystem || backgroundSyncDisabledInApp || networkNotAvailable) {
|
||||
emptyList()
|
||||
} else {
|
||||
realPushAccounts
|
||||
}
|
||||
val pushAccountUuids = pushAccounts.map { it.uuid }
|
||||
|
||||
val arePushersActive = synchronized(lock) {
|
||||
val currentPushAccountUuids = pushers.keys
|
||||
val startPushAccountUuids = pushAccountUuids - currentPushAccountUuids
|
||||
val stopPushAccountUuids = currentPushAccountUuids - pushAccountUuids
|
||||
|
||||
if (stopPushAccountUuids.isNotEmpty()) {
|
||||
Timber.v("..Stopping PushController for accounts: %s", stopPushAccountUuids)
|
||||
for (accountUuid in stopPushAccountUuids) {
|
||||
val accountPushController = pushers.remove(accountUuid)
|
||||
accountPushController?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
if (startPushAccountUuids.isNotEmpty()) {
|
||||
Timber.v("..Starting PushController for accounts: %s", startPushAccountUuids)
|
||||
for (accountUuid in startPushAccountUuids) {
|
||||
val account = preferences.getAccount(accountUuid) ?: error("Account not found: $accountUuid")
|
||||
pushers[accountUuid] = accountPushControllerFactory.create(account).also { accountPushController ->
|
||||
accountPushController.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timber.v("..Running PushControllers: %s", pushers.keys)
|
||||
|
||||
pushers.isNotEmpty()
|
||||
}
|
||||
|
||||
when {
|
||||
realPushAccounts.isEmpty() -> {
|
||||
stopServices()
|
||||
}
|
||||
backgroundSyncDisabledViaSystem -> {
|
||||
setPushNotificationState(WAIT_BACKGROUND_SYNC)
|
||||
startServices()
|
||||
}
|
||||
networkNotAvailable -> {
|
||||
setPushNotificationState(WAIT_NETWORK)
|
||||
startServices()
|
||||
}
|
||||
arePushersActive -> {
|
||||
setPushNotificationState(LISTENING)
|
||||
startServices()
|
||||
}
|
||||
else -> {
|
||||
stopServices()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPushAccounts(): List<Account> {
|
||||
return preferences.accounts.filter { account ->
|
||||
account.folderPushMode != FolderMode.NONE && backendManager.getBackend(account).isPushCapable
|
||||
}
|
||||
}
|
||||
private fun setPushNotificationState(notificationState: PushNotificationState) {
|
||||
pushNotificationManager.notificationState = notificationState
|
||||
}
|
||||
|
||||
private fun startServices() {
|
||||
pushServiceManager.start()
|
||||
bootCompleteManager.enableReceiver()
|
||||
registerAutoSyncListener()
|
||||
registerConnectivityChangeListener()
|
||||
connectivityManager.start()
|
||||
}
|
||||
|
||||
private fun stopServices() {
|
||||
pushServiceManager.stop()
|
||||
bootCompleteManager.disableReceiver()
|
||||
autoSyncManager.unregisterListener()
|
||||
unregisterConnectivityChangeListener()
|
||||
connectivityManager.stop()
|
||||
}
|
||||
|
||||
private fun registerAutoSyncListener() {
|
||||
if (autoSyncManager.respectSystemAutoSync) {
|
||||
autoSyncManager.registerListener(autoSyncListener)
|
||||
} else {
|
||||
autoSyncManager.unregisterListener()
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerConnectivityChangeListener() {
|
||||
connectivityManager.addListener(connectivityChangeListener)
|
||||
}
|
||||
|
||||
private fun unregisterConnectivityChangeListener() {
|
||||
connectivityManager.removeListener(connectivityChangeListener)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
package com.fsck.k9.controller.push
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import com.fsck.k9.notification.PushNotificationManager
|
||||
import org.koin.android.ext.android.inject
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Foreground service that is used to keep the app alive while listening for new emails (Push).
|
||||
*/
|
||||
class PushService : Service() {
|
||||
private val pushNotificationManager: PushNotificationManager by inject()
|
||||
private val pushController: PushController by inject()
|
||||
|
||||
override fun onCreate() {
|
||||
Timber.v("PushService.onCreate()")
|
||||
super.onCreate()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Timber.v("PushService.onStartCommand()")
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
|
||||
startForeground()
|
||||
initializePushController()
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Timber.v("PushService.onDestroy()")
|
||||
pushNotificationManager.setForegroundServiceStopped()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun startForeground() {
|
||||
val notificationId = pushNotificationManager.notificationId
|
||||
val notification = pushNotificationManager.createForegroundNotification()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
startForeground(notificationId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(notificationId, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializePushController() {
|
||||
// When the app is killed by the system and later recreated to start this service nobody else is initializing
|
||||
// PushController. So we'll have to do it here.
|
||||
pushController.init()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package com.fsck.k9.controller.push
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Manages starting and stopping [PushService].
|
||||
*/
|
||||
internal class PushServiceManager(private val context: Context) {
|
||||
private var isServiceStarted = AtomicBoolean(false)
|
||||
|
||||
fun start() {
|
||||
Timber.v("PushServiceManager.start()")
|
||||
if (isServiceStarted.compareAndSet(false, true)) {
|
||||
startService()
|
||||
} else {
|
||||
Timber.v("..PushService already running")
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
Timber.v("PushServiceManager.stop()")
|
||||
if (isServiceStarted.compareAndSet(true, false)) {
|
||||
stopService()
|
||||
} else {
|
||||
Timber.v("..PushService is not running")
|
||||
}
|
||||
}
|
||||
|
||||
private fun startService() {
|
||||
try {
|
||||
val intent = Intent(context, PushService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Exception while trying to start PushService")
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopService() {
|
||||
try {
|
||||
val intent = Intent(context, PushService::class.java)
|
||||
context.stopService(intent)
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Exception while trying to stop PushService")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.fsck.k9.crypto
|
||||
|
||||
import android.content.ContentValues
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.message.extractors.PreviewResult
|
||||
|
||||
interface EncryptionExtractor {
|
||||
fun extractEncryption(message: Message): EncryptionResult?
|
||||
}
|
||||
|
||||
data class EncryptionResult(
|
||||
val encryptionType: String,
|
||||
val attachmentCount: Int,
|
||||
val previewResult: PreviewResult = PreviewResult.encrypted(),
|
||||
val textForSearchIndex: String? = null,
|
||||
val extraContentValues: ContentValues? = null
|
||||
)
|
||||
11
app/core/src/main/java/com/fsck/k9/crypto/KoinModule.kt
Normal file
11
app/core/src/main/java/com/fsck/k9/crypto/KoinModule.kt
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package com.fsck.k9.crypto
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import org.koin.dsl.module
|
||||
import org.openintents.openpgp.OpenPgpApiManager
|
||||
|
||||
val openPgpModule = module {
|
||||
factory { (lifecycleOwner: LifecycleOwner) ->
|
||||
OpenPgpApiManager(get(), lifecycleOwner)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
package com.fsck.k9.crypto;
|
||||
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Stack;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
|
||||
import com.fsck.k9.helper.StringHelper;
|
||||
import com.fsck.k9.mail.Body;
|
||||
import com.fsck.k9.mail.BodyPart;
|
||||
import com.fsck.k9.mail.MessagingException;
|
||||
import com.fsck.k9.mail.Multipart;
|
||||
import com.fsck.k9.mail.Part;
|
||||
import com.fsck.k9.mail.internet.MessageExtractor;
|
||||
import com.fsck.k9.mail.internet.MimeBodyPart;
|
||||
import com.fsck.k9.mail.internet.MimeMultipart;
|
||||
import com.fsck.k9.mail.internet.MimeUtility;
|
||||
import com.fsck.k9.mailstore.CryptoResultAnnotation;
|
||||
import com.fsck.k9.mailstore.MessageCryptoAnnotations;
|
||||
|
||||
import static com.fsck.k9.mail.internet.MimeUtility.isSameMimeType;
|
||||
|
||||
|
||||
public class MessageCryptoStructureDetector {
|
||||
private static final String MULTIPART_ENCRYPTED = "multipart/encrypted";
|
||||
private static final String MULTIPART_SIGNED = "multipart/signed";
|
||||
private static final String PROTOCOL_PARAMETER = "protocol";
|
||||
private static final String APPLICATION_PGP_ENCRYPTED = "application/pgp-encrypted";
|
||||
private static final String APPLICATION_PGP_SIGNATURE = "application/pgp-signature";
|
||||
private static final String TEXT_PLAIN = "text/plain";
|
||||
// APPLICATION/PGP is a special case which occurs from mutt. see http://www.mutt.org/doc/PGP-Notes.txt
|
||||
private static final String APPLICATION_PGP = "application/pgp";
|
||||
|
||||
private static final String PGP_INLINE_START_MARKER = "-----BEGIN PGP MESSAGE-----";
|
||||
private static final String PGP_INLINE_SIGNED_START_MARKER = "-----BEGIN PGP SIGNED MESSAGE-----";
|
||||
private static final int TEXT_LENGTH_FOR_INLINE_CHECK = 36;
|
||||
|
||||
|
||||
public static Part findPrimaryEncryptedOrSignedPart(Part part, List<Part> outputExtraParts) {
|
||||
if (isPartEncryptedOrSigned(part)) {
|
||||
return part;
|
||||
}
|
||||
|
||||
Part foundPart;
|
||||
|
||||
foundPart = findPrimaryPartInAlternative(part);
|
||||
if (foundPart != null) {
|
||||
return foundPart;
|
||||
}
|
||||
|
||||
foundPart = findPrimaryPartInMixed(part, outputExtraParts);
|
||||
if (foundPart != null) {
|
||||
return foundPart;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static Part findPrimaryPartInMixed(Part part, List<Part> outputExtraParts) {
|
||||
Body body = part.getBody();
|
||||
|
||||
boolean isMultipartMixed = part.isMimeType("multipart/mixed") && body instanceof Multipart;
|
||||
if (!isMultipartMixed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Multipart multipart = (Multipart) body;
|
||||
if (multipart.getCount() == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
BodyPart firstBodyPart = multipart.getBodyPart(0);
|
||||
|
||||
Part foundPart;
|
||||
if (isPartEncryptedOrSigned(firstBodyPart)) {
|
||||
foundPart = firstBodyPart;
|
||||
} else {
|
||||
foundPart = findPrimaryPartInAlternative(firstBodyPart);
|
||||
}
|
||||
|
||||
if (foundPart != null && outputExtraParts != null) {
|
||||
for (int i = 1; i < multipart.getCount(); i++) {
|
||||
outputExtraParts.add(multipart.getBodyPart(i));
|
||||
}
|
||||
}
|
||||
|
||||
return foundPart;
|
||||
}
|
||||
|
||||
private static Part findPrimaryPartInAlternative(Part part) {
|
||||
Body body = part.getBody();
|
||||
if (part.isMimeType("multipart/alternative") && body instanceof Multipart) {
|
||||
Multipart multipart = (Multipart) body;
|
||||
if (multipart.getCount() == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
BodyPart firstBodyPart = multipart.getBodyPart(0);
|
||||
if (isPartPgpInlineEncryptedOrSigned(firstBodyPart)) {
|
||||
return firstBodyPart;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static List<Part> findMultipartEncryptedParts(Part startPart) {
|
||||
List<Part> encryptedParts = new ArrayList<>();
|
||||
Stack<Part> partsToCheck = new Stack<>();
|
||||
partsToCheck.push(startPart);
|
||||
|
||||
while (!partsToCheck.isEmpty()) {
|
||||
Part part = partsToCheck.pop();
|
||||
Body body = part.getBody();
|
||||
|
||||
if (isPartMultipartEncrypted(part)) {
|
||||
encryptedParts.add(part);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (body instanceof Multipart) {
|
||||
Multipart multipart = (Multipart) body;
|
||||
for (int i = multipart.getCount() - 1; i >= 0; i--) {
|
||||
BodyPart bodyPart = multipart.getBodyPart(i);
|
||||
partsToCheck.push(bodyPart);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return encryptedParts;
|
||||
}
|
||||
|
||||
public static List<Part> findMultipartSignedParts(Part startPart, MessageCryptoAnnotations messageCryptoAnnotations) {
|
||||
List<Part> signedParts = new ArrayList<>();
|
||||
Stack<Part> partsToCheck = new Stack<>();
|
||||
partsToCheck.push(startPart);
|
||||
|
||||
while (!partsToCheck.isEmpty()) {
|
||||
Part part = partsToCheck.pop();
|
||||
if (messageCryptoAnnotations.has(part)) {
|
||||
CryptoResultAnnotation resultAnnotation = messageCryptoAnnotations.get(part);
|
||||
MimeBodyPart replacementData = resultAnnotation.getReplacementData();
|
||||
if (replacementData != null) {
|
||||
part = replacementData;
|
||||
}
|
||||
}
|
||||
Body body = part.getBody();
|
||||
|
||||
if (isPartMultipartSigned(part)) {
|
||||
signedParts.add(part);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (body instanceof Multipart) {
|
||||
Multipart multipart = (Multipart) body;
|
||||
for (int i = multipart.getCount() - 1; i >= 0; i--) {
|
||||
BodyPart bodyPart = multipart.getBodyPart(i);
|
||||
partsToCheck.push(bodyPart);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return signedParts;
|
||||
}
|
||||
|
||||
public static List<Part> findPgpInlineParts(Part startPart) {
|
||||
List<Part> inlineParts = new ArrayList<>();
|
||||
Stack<Part> partsToCheck = new Stack<>();
|
||||
partsToCheck.push(startPart);
|
||||
|
||||
while (!partsToCheck.isEmpty()) {
|
||||
Part part = partsToCheck.pop();
|
||||
Body body = part.getBody();
|
||||
|
||||
if (isPartPgpInlineEncryptedOrSigned(part)) {
|
||||
inlineParts.add(part);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (body instanceof Multipart) {
|
||||
Multipart multipart = (Multipart) body;
|
||||
for (int i = multipart.getCount() - 1; i >= 0; i--) {
|
||||
BodyPart bodyPart = multipart.getBodyPart(i);
|
||||
partsToCheck.push(bodyPart);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return inlineParts;
|
||||
}
|
||||
|
||||
public static byte[] getSignatureData(Part part) throws IOException, MessagingException {
|
||||
if (isPartMultipartSigned(part)) {
|
||||
Body body = part.getBody();
|
||||
if (body instanceof Multipart) {
|
||||
Multipart multi = (Multipart) body;
|
||||
BodyPart signatureBody = multi.getBodyPart(1);
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
signatureBody.getBody().writeTo(bos);
|
||||
return bos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isPartEncryptedOrSigned(Part part) {
|
||||
return isPartMultipartEncrypted(part) || isPartMultipartSigned(part) || isPartPgpInlineEncryptedOrSigned(part);
|
||||
}
|
||||
|
||||
private static boolean isPartMultipartSigned(Part part) {
|
||||
if (!isSameMimeType(part.getMimeType(), MULTIPART_SIGNED)) {
|
||||
return false;
|
||||
}
|
||||
if (! (part.getBody() instanceof MimeMultipart)) {
|
||||
return false;
|
||||
}
|
||||
MimeMultipart mimeMultipart = (MimeMultipart) part.getBody();
|
||||
if (mimeMultipart.getCount() != 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String protocolParameter = MimeUtility.getHeaderParameter(part.getContentType(), PROTOCOL_PARAMETER);
|
||||
|
||||
// for partially downloaded messages the protocol parameter isn't yet available, so we'll just assume it's ok
|
||||
boolean dataUnavailable = protocolParameter == null && mimeMultipart.getBodyPart(0).getBody() == null;
|
||||
boolean protocolMatches = isSameMimeType(protocolParameter, mimeMultipart.getBodyPart(1).getMimeType());
|
||||
return dataUnavailable || protocolMatches;
|
||||
}
|
||||
|
||||
public static boolean isPartMultipartEncrypted(Part part) {
|
||||
if (!isSameMimeType(part.getMimeType(), MULTIPART_ENCRYPTED)) {
|
||||
return false;
|
||||
}
|
||||
if (! (part.getBody() instanceof MimeMultipart)) {
|
||||
return false;
|
||||
}
|
||||
MimeMultipart mimeMultipart = (MimeMultipart) part.getBody();
|
||||
if (mimeMultipart.getCount() != 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String protocolParameter = MimeUtility.getHeaderParameter(part.getContentType(), PROTOCOL_PARAMETER);
|
||||
|
||||
// for partially downloaded messages the protocol parameter isn't yet available, so we'll just assume it's ok
|
||||
boolean dataUnavailable = protocolParameter == null && mimeMultipart.getBodyPart(1).getBody() == null;
|
||||
boolean protocolMatches = isSameMimeType(protocolParameter, mimeMultipart.getBodyPart(0).getMimeType());
|
||||
return dataUnavailable || protocolMatches;
|
||||
}
|
||||
|
||||
public static boolean isMultipartEncryptedOpenPgpProtocol(Part part) {
|
||||
if (!isSameMimeType(part.getMimeType(), MULTIPART_ENCRYPTED)) {
|
||||
throw new IllegalArgumentException("Part is not multipart/encrypted!");
|
||||
}
|
||||
|
||||
String protocolParameter = MimeUtility.getHeaderParameter(part.getContentType(), PROTOCOL_PARAMETER);
|
||||
return APPLICATION_PGP_ENCRYPTED.equalsIgnoreCase(protocolParameter);
|
||||
}
|
||||
|
||||
public static boolean isMultipartSignedOpenPgpProtocol(Part part) {
|
||||
if (!isSameMimeType(part.getMimeType(), MULTIPART_SIGNED)) {
|
||||
throw new IllegalArgumentException("Part is not multipart/signed!");
|
||||
}
|
||||
|
||||
String protocolParameter = MimeUtility.getHeaderParameter(part.getContentType(), PROTOCOL_PARAMETER);
|
||||
return APPLICATION_PGP_SIGNATURE.equalsIgnoreCase(protocolParameter);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static boolean isPartPgpInlineEncryptedOrSigned(Part part) {
|
||||
if (!part.isMimeType(TEXT_PLAIN) && !part.isMimeType(APPLICATION_PGP)) {
|
||||
return false;
|
||||
}
|
||||
String text = MessageExtractor.getTextFromPart(part, TEXT_LENGTH_FOR_INLINE_CHECK);
|
||||
if (StringHelper.isNullOrEmpty(text)) {
|
||||
return false;
|
||||
}
|
||||
text = text.trim();
|
||||
return text.startsWith(PGP_INLINE_START_MARKER) || text.startsWith(PGP_INLINE_SIGNED_START_MARKER);
|
||||
}
|
||||
|
||||
public static boolean isPartPgpInlineEncrypted(@Nullable Part part) {
|
||||
if (part == null) {
|
||||
return false;
|
||||
}
|
||||
if (!part.isMimeType(TEXT_PLAIN) && !part.isMimeType(APPLICATION_PGP)) {
|
||||
return false;
|
||||
}
|
||||
String text = MessageExtractor.getTextFromPart(part, TEXT_LENGTH_FOR_INLINE_CHECK);
|
||||
if (StringHelper.isNullOrEmpty(text)) {
|
||||
return false;
|
||||
}
|
||||
text = text.trim();
|
||||
return text.startsWith(PGP_INLINE_START_MARKER);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package com.fsck.k9.crypto;
|
||||
|
||||
|
||||
import com.fsck.k9.Identity;
|
||||
import com.fsck.k9.helper.StringHelper;
|
||||
|
||||
|
||||
public class OpenPgpApiHelper {
|
||||
|
||||
/**
|
||||
* Create an "account name" from the supplied identity for use with the OpenPgp API's
|
||||
* <code>EXTRA_ACCOUNT_NAME</code>.
|
||||
*
|
||||
* @return A string with the following format:
|
||||
* <code>display name <user@example.com></code>
|
||||
*/
|
||||
public static String buildUserId(Identity identity) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
String name = identity.getName();
|
||||
if (!StringHelper.isNullOrEmpty(name)) {
|
||||
sb.append(name).append(" ");
|
||||
}
|
||||
sb.append("<").append(identity.getEmail()).append(">");
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.fsck.k9.helper
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.os.Build
|
||||
|
||||
class AlarmManagerCompat(private val alarmManager: AlarmManager) {
|
||||
fun scheduleAlarm(triggerAtMillis: Long, operation: PendingIntent) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
alarmManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, triggerAtMillis, operation)
|
||||
} else {
|
||||
alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, triggerAtMillis, operation)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelAlarm(operation: PendingIntent) {
|
||||
alarmManager.cancel(operation)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.fsck.k9.helper
|
||||
|
||||
import android.content.Context
|
||||
import com.fsck.k9.mail.ssl.KeyStoreDirectoryProvider
|
||||
import java.io.File
|
||||
|
||||
internal class AndroidKeyStoreDirectoryProvider(private val context: Context) : KeyStoreDirectoryProvider {
|
||||
override fun getDirectory(): File {
|
||||
return context.getDir("KeyStore", Context.MODE_PRIVATE)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.fsck.k9.helper
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* Access the system clipboard
|
||||
*/
|
||||
class ClipboardManager(private val context: Context) {
|
||||
|
||||
/**
|
||||
* Copy a text string to the system clipboard
|
||||
*
|
||||
* @param label User-visible label for the content.
|
||||
* @param text The actual text to be copied to the clipboard.
|
||||
*/
|
||||
fun setText(label: String, text: String) {
|
||||
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||
val clip = ClipData.newPlainText(label, text)
|
||||
clipboardManager.setPrimaryClip(clip)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.fsck.k9.helper
|
||||
|
||||
/**
|
||||
* Returns a [Set] containing the results of applying the given [transform] function to each element in the original
|
||||
* collection.
|
||||
*
|
||||
* If you know the size of the output or can make an educated guess, specify [expectedSize] as an optimization.
|
||||
* The initial capacity of the `Set` will be derived from this value.
|
||||
*/
|
||||
inline fun <T, R> Iterable<T>.mapToSet(expectedSize: Int? = null, transform: (T) -> R): Set<R> {
|
||||
return if (expectedSize != null) {
|
||||
mapTo(LinkedHashSet(setCapacity(expectedSize)), transform)
|
||||
} else {
|
||||
mapTo(mutableSetOf(), transform)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a [Set] containing the results of applying the given [transform] function to each element in the original
|
||||
* collection.
|
||||
*
|
||||
* The size of the output is expected to be equal to the size of the input. If that's not the case, please use
|
||||
* [mapToSet] instead.
|
||||
*/
|
||||
inline fun <T, R> Collection<T>.mapCollectionToSet(transform: (T) -> R): Set<R> {
|
||||
return mapToSet(expectedSize = size, transform)
|
||||
}
|
||||
|
||||
// A copy of Kotlin's internal mapCapacity() for the JVM
|
||||
fun setCapacity(expectedSize: Int): Int = when {
|
||||
// We are not coercing the value to a valid one and not throwing an exception. It is up to the caller to
|
||||
// properly handle negative values.
|
||||
expectedSize < 0 -> expectedSize
|
||||
expectedSize < 3 -> expectedSize + 1
|
||||
expectedSize < INT_MAX_POWER_OF_TWO -> ((expectedSize / 0.75F) + 1.0F).toInt()
|
||||
// any large value
|
||||
else -> Int.MAX_VALUE
|
||||
}
|
||||
|
||||
private const val INT_MAX_POWER_OF_TWO: Int = 1 shl (Int.SIZE_BITS - 2)
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.fsck.k9.helper
|
||||
|
||||
import app.k9mail.core.android.common.contact.ContactRepository
|
||||
import app.k9mail.core.common.mail.EmailAddress
|
||||
|
||||
interface ContactNameProvider {
|
||||
fun getNameForAddress(address: String): String?
|
||||
}
|
||||
|
||||
class RealContactNameProvider(
|
||||
private val contactRepository: ContactRepository,
|
||||
) : ContactNameProvider {
|
||||
override fun getNameForAddress(address: String): String? {
|
||||
return contactRepository.getContactFor(EmailAddress(address))?.name
|
||||
}
|
||||
}
|
||||
16
app/core/src/main/java/com/fsck/k9/helper/Contacts.kt
Normal file
16
app/core/src/main/java/com/fsck/k9/helper/Contacts.kt
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package com.fsck.k9.helper
|
||||
|
||||
import com.fsck.k9.mail.Address
|
||||
|
||||
/**
|
||||
* Helper class to access the contacts stored on the device.
|
||||
*/
|
||||
class Contacts {
|
||||
/**
|
||||
* Mark contacts with the provided email addresses as contacted.
|
||||
*/
|
||||
fun markAsContacted(addresses: Array<Address?>?) {
|
||||
// TODO: Keep track of this information in a local database. Then use this information when sorting contacts for
|
||||
// auto-completion.
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
@file:JvmName("CrLfConverter")
|
||||
|
||||
package com.fsck.k9.helper
|
||||
|
||||
fun String?.toLf() = this?.replace("\r\n", "\n")
|
||||
|
||||
fun CharSequence?.toLf() = this?.toString()?.replace("\r\n", "\n")
|
||||
|
||||
fun CharSequence?.toCrLf() = this?.toString()?.replace("\n", "\r\n")
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue