Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-24 18:55:42 +01:00
parent a629de6271
commit 3cef7c5092
2161 changed files with 246605 additions and 2 deletions

51
app/core/build.gradle.kts Normal file
View 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
}
}

View 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>

View 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
}
}

View file

@ -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"
}
}

View file

@ -0,0 +1,5 @@
package com.fsck.k9
fun interface AccountRemovedListener {
fun onAccountRemoved(account: Account)
}

View file

@ -0,0 +1,6 @@
package com.fsck.k9;
public interface AccountsChangeListener {
void onAccountsChanged();
}

View 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()
}

View file

@ -0,0 +1,5 @@
package com.fsck.k9
data class AppConfig(
val componentsToDisable: List<Class<*>>
)

View file

@ -0,0 +1,7 @@
package com.fsck.k9
interface BaseAccount {
val uuid: String
val name: String?
val email: String
}

View 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)
}
}
}

View 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
)

View 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
}

View 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) }

View 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"
)
}
}

View 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);
}
}
}

View 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)
}

View 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
}
}

View 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() }
}

View 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)
}
}
}

View 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()
}

View 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
)

View 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")
}
}
}

View 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()
}
}
}

View 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;
}
}
}

View 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()
}
}

View 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()
}

View 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)
}

View 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
)
}
}

View file

@ -0,0 +1,7 @@
package com.fsck.k9
enum class UiDensity {
Compact,
Default,
Relaxed,
}

View file

@ -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()
)
}
}
}

View file

@ -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
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,6 @@
package com.fsck.k9.autocrypt
interface AutocryptStringProvider {
fun transferMessageSubject(): String
fun transferMessageBody(): String
}

View file

@ -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)
}
}
}

View file

@ -0,0 +1,8 @@
package com.fsck.k9.autocrypt
import org.koin.dsl.module
val autocryptModule = module {
single { AutocryptTransferMessageCreator(get()) }
single { AutocryptDraftStateHeaderParser() }
}

View file

@ -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
}

View 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)
}

View file

@ -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))
}
}
}

View file

@ -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)
}
}

View 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)
}
}

View 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()
)
}
}

View file

@ -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;
}
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}
}

View file

@ -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

View file

@ -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);
}
}
}

View file

@ -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);
}

View file

@ -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())
}
}

View file

@ -0,0 +1,6 @@
package com.fsck.k9.controller
class NotificationState {
@get:JvmName("wasNotified")
var wasNotified: Boolean = false
}

View file

@ -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);
}
}
}

View file

@ -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" }
}

View file

@ -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);
}
}

View file

@ -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) {
}
}

View file

@ -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;
}
}
}

View file

@ -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)
}
}

View file

@ -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
)
}
}

View file

@ -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()
}

View file

@ -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")
}
}
}

View file

@ -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()
)
}
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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")
}
}
}

View file

@ -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
)

View 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)
}
}

View file

@ -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);
}
}

View file

@ -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 &lt;user@example.com&gt;</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();
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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
}
}

View 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.
}
}

View file

@ -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")

View file

@ -0,0 +1,167 @@
package com.fsck.k9.helper;
import java.io.IOException;
import java.net.Socket;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import android.content.Context;
import android.net.SSLCertificateSocketFactory;
import android.os.Build;
import android.text.TextUtils;
import com.fsck.k9.mail.MessagingException;
import com.fsck.k9.mail.ssl.TrustManagerFactory;
import com.fsck.k9.mail.ssl.TrustedSocketFactory;
import javax.net.ssl.KeyManager;
import javax.net.ssl.SNIHostName;
import javax.net.ssl.SNIServerName;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import timber.log.Timber;
public class DefaultTrustedSocketFactory implements TrustedSocketFactory {
private static final String[] ENABLED_CIPHERS;
private static final String[] ENABLED_PROTOCOLS;
private static final String[] DISALLOWED_CIPHERS = {
"SSL_RSA_WITH_DES_CBC_SHA",
"SSL_DHE_RSA_WITH_DES_CBC_SHA",
"SSL_DHE_DSS_WITH_DES_CBC_SHA",
"SSL_RSA_EXPORT_WITH_RC4_40_MD5",
"SSL_RSA_EXPORT_WITH_DES40_CBC_SHA",
"SSL_DHE_RSA_EXPORT_WITH_DES40_CBC_SHA",
"SSL_DHE_DSS_EXPORT_WITH_DES40_CBC_SHA",
"SSL_RSA_WITH_3DES_EDE_CBC_SHA",
"SSL_DHE_RSA_WITH_3DES_EDE_CBC_SHA",
"SSL_DHE_DSS_WITH_3DES_EDE_CBC_SHA",
"TLS_ECDHE_RSA_WITH_RC4_128_SHA",
"TLS_ECDHE_ECDSA_WITH_RC4_128_SHA",
"TLS_ECDH_RSA_WITH_RC4_128_SHA",
"TLS_ECDH_ECDSA_WITH_RC4_128_SHA",
"SSL_RSA_WITH_RC4_128_SHA",
"SSL_RSA_WITH_RC4_128_MD5",
"TLS_ECDH_RSA_WITH_NULL_SHA",
"TLS_ECDH_anon_WITH_3DES_EDE_CBC_SHA",
"TLS_ECDH_anon_WITH_NULL_SHA",
"TLS_ECDH_anon_WITH_RC4_128_SHA",
"TLS_RSA_WITH_NULL_SHA256"
};
private static final String[] DISALLOWED_PROTOCOLS = {
"SSLv3"
};
static {
String[] enabledCiphers = null;
String[] supportedProtocols = null;
try {
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, null, null);
SSLSocketFactory sf = sslContext.getSocketFactory();
SSLSocket sock = (SSLSocket) sf.createSocket();
enabledCiphers = sock.getEnabledCipherSuites();
/*
* Retrieve all supported protocols, not just the (default) enabled
* ones. TLSv1.1 & TLSv1.2 are supported on API levels 16+, but are
* only enabled by default on API levels 20+.
*/
supportedProtocols = sock.getSupportedProtocols();
} catch (Exception e) {
Timber.e(e, "Error getting information about available SSL/TLS ciphers and protocols");
}
ENABLED_CIPHERS = (enabledCiphers == null) ? null : remove(enabledCiphers, DISALLOWED_CIPHERS);
ENABLED_PROTOCOLS = (supportedProtocols == null) ? null : remove(supportedProtocols, DISALLOWED_PROTOCOLS);
}
private final Context context;
private final TrustManagerFactory trustManagerFactory;
public DefaultTrustedSocketFactory(Context context, TrustManagerFactory trustManagerFactory) {
this.context = context;
this.trustManagerFactory = trustManagerFactory;
}
protected static String[] remove(String[] enabled, String[] disallowed) {
List<String> items = new ArrayList<>();
Collections.addAll(items, enabled);
if (disallowed != null) {
for (String item : disallowed) {
items.remove(item);
}
}
return items.toArray(new String[0]);
}
public Socket createSocket(Socket socket, String host, int port, String clientCertificateAlias)
throws NoSuchAlgorithmException, KeyManagementException, MessagingException, IOException {
TrustManager[] trustManagers = new TrustManager[] { trustManagerFactory.getTrustManagerForDomain(host, port) };
KeyManager[] keyManagers = null;
if (!TextUtils.isEmpty(clientCertificateAlias)) {
keyManagers = new KeyManager[] { new KeyChainKeyManager(context, clientCertificateAlias) };
}
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(keyManagers, trustManagers, null);
SSLSocketFactory socketFactory = sslContext.getSocketFactory();
Socket trustedSocket;
if (socket == null) {
trustedSocket = socketFactory.createSocket();
} else {
trustedSocket = socketFactory.createSocket(socket, host, port, true);
}
SSLSocket sslSocket = (SSLSocket) trustedSocket;
hardenSocket(sslSocket);
setSniHost(socketFactory, sslSocket, host);
return trustedSocket;
}
private static void hardenSocket(SSLSocket sock) {
if (ENABLED_CIPHERS != null) {
sock.setEnabledCipherSuites(ENABLED_CIPHERS);
}
if (ENABLED_PROTOCOLS != null) {
sock.setEnabledProtocols(ENABLED_PROTOCOLS);
}
}
public static void setSniHost(SSLSocketFactory factory, SSLSocket socket, String hostname) {
if (factory instanceof android.net.SSLCertificateSocketFactory) {
SSLCertificateSocketFactory sslCertificateSocketFactory = (SSLCertificateSocketFactory) factory;
sslCertificateSocketFactory.setHostname(socket, hostname);
} else if (Build.VERSION.SDK_INT >= 24) {
SSLParameters sslParameters = socket.getSSLParameters();
List<SNIServerName> sniServerNames = Collections.singletonList(new SNIHostName(hostname));
sslParameters.setServerNames(sniServerNames);
socket.setSSLParameters(sslParameters);
} else {
setHostnameViaReflection(socket, hostname);
}
}
private static void setHostnameViaReflection(SSLSocket socket, String hostname) {
try {
socket.getClass().getMethod("setHostname", String.class).invoke(socket, hostname);
} catch (Throwable e) {
Timber.e(e, "Could not call SSLSocket#setHostname(String) method ");
}
}
}

View file

@ -0,0 +1,147 @@
package com.fsck.k9.helper;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import timber.log.Timber;
import org.apache.commons.io.IOUtils;
public class FileHelper {
public static void touchFile(final File parentDir, final String name) {
final File file = new File(parentDir, name);
try {
if (!file.exists()) {
if (!file.createNewFile()) {
Timber.d("Unable to create file: %s", file.getAbsolutePath());
}
} else {
if (!file.setLastModified(System.currentTimeMillis())) {
Timber.d("Unable to change last modification date: %s", file.getAbsolutePath());
}
}
} catch (Exception e) {
Timber.d(e, "Unable to touch file: %s", file.getAbsolutePath());
}
}
private static void copyFile(File from, File to) throws IOException {
FileInputStream in = new FileInputStream(from);
FileOutputStream out = new FileOutputStream(to);
try {
byte[] buffer = new byte[1024];
int count;
while ((count = in.read(buffer)) > 0) {
out.write(buffer, 0, count);
}
out.close();
} finally {
IOUtils.closeQuietly(in);
IOUtils.closeQuietly(out);
}
}
public static void renameOrMoveByCopying(File from, File to) throws IOException {
deleteFileIfExists(to);
boolean renameFailed = !from.renameTo(to);
if (renameFailed) {
copyFile(from, to);
boolean deleteFromFailed = !from.delete();
if (deleteFromFailed) {
Timber.e("Unable to delete source file after copying to destination!");
}
}
}
private static void deleteFileIfExists(File to) throws IOException {
boolean fileDoesNotExist = !to.exists();
if (fileDoesNotExist) {
return;
}
boolean deleteOk = to.delete();
if (deleteOk) {
return;
}
throw new IOException("Unable to delete file: " + to.getAbsolutePath());
}
public static boolean move(final File from, final File to) {
if (to.exists()) {
if (!to.delete()) {
Timber.d("Unable to delete file: %s", to.getAbsolutePath());
}
}
if (!to.getParentFile().mkdirs()) {
Timber.d("Unable to make directories: %s", to.getParentFile().getAbsolutePath());
}
try {
copyFile(from, to);
boolean deleteFromFailed = !from.delete();
if (deleteFromFailed) {
Timber.e("Unable to delete source file after copying to destination!");
}
return true;
} catch (Exception e) {
Timber.w(e, "cannot move %s to %s", from.getAbsolutePath(), to.getAbsolutePath());
return false;
}
}
public static void moveRecursive(final File fromDir, final File toDir) {
if (!fromDir.exists()) {
return;
}
if (!fromDir.isDirectory()) {
if (toDir.exists()) {
if (!toDir.delete()) {
Timber.w("cannot delete already existing file/directory %s", toDir.getAbsolutePath());
}
}
if (!fromDir.renameTo(toDir)) {
Timber.w("cannot rename %s to %s - moving instead", fromDir.getAbsolutePath(), toDir.getAbsolutePath());
move(fromDir, toDir);
}
return;
}
if (!toDir.exists() || !toDir.isDirectory()) {
if (toDir.exists()) {
if (!toDir.delete()) {
Timber.d("Unable to delete file: %s", toDir.getAbsolutePath());
}
}
if (!toDir.mkdirs()) {
Timber.w("cannot create directory %s", toDir.getAbsolutePath());
}
}
File[] files = fromDir.listFiles();
for (File file : files) {
if (file.isDirectory()) {
moveRecursive(file, new File(toDir, file.getName()));
if (!file.delete()) {
Timber.d("Unable to delete file: %s", toDir.getAbsolutePath());
}
} else {
File target = new File(toDir, file.getName());
if (!file.renameTo(target)) {
Timber.w("cannot rename %s to %s - moving instead",
file.getAbsolutePath(), target.getAbsolutePath());
move(file, target);
}
}
}
if (!fromDir.delete()) {
Timber.w("cannot delete %s", fromDir.getAbsolutePath());
}
}
}

View file

@ -0,0 +1,40 @@
package com.fsck.k9.helper
import com.fsck.k9.Account
import com.fsck.k9.Identity
import com.fsck.k9.mail.Message
import com.fsck.k9.mail.Message.RecipientType
object IdentityHelper {
private val RECIPIENT_TYPES = listOf(
RecipientType.TO,
RecipientType.CC,
RecipientType.X_ORIGINAL_TO,
RecipientType.DELIVERED_TO,
RecipientType.X_ENVELOPE_TO
)
/**
* Find the identity a message was sent to.
*
* @param account
* The account the message belongs to.
* @param message
* The message to get the recipients from.
*
* @return The identity the message was sent to, or the account's default identity if it
* couldn't be determined which identity this message was sent to.
*
* @see Account.findIdentity
*/
@JvmStatic
fun getRecipientIdentityFromMessage(account: Account, message: Message): Identity {
val recipient: Identity? = RECIPIENT_TYPES.asSequence()
.flatMap { recipientType -> message.getRecipients(recipientType).asSequence() }
.map { address -> account.findIdentity(address) }
.filterNotNull()
.firstOrNull()
return recipient ?: account.getIdentity(0)
}
}

View file

@ -0,0 +1,190 @@
package com.fsck.k9.helper;
import java.net.Socket;
import java.security.Principal;
import java.security.PrivateKey;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import javax.net.ssl.SSLEngine;
import javax.net.ssl.X509ExtendedKeyManager;
import javax.security.auth.x500.X500Principal;
import android.content.Context;
import android.security.KeyChain;
import android.security.KeyChainException;
import com.fsck.k9.mail.CertificateValidationException;
import com.fsck.k9.mail.MessagingException;
import timber.log.Timber;
import static com.fsck.k9.mail.CertificateValidationException.Reason;
import static com.fsck.k9.mail.CertificateValidationException.Reason.RetrievalFailure;
/**
* For client certificate authentication! Provide private keys and certificates
* during the TLS handshake using the Android 4.0 KeyChain API.
*/
class KeyChainKeyManager extends X509ExtendedKeyManager {
private final String mAlias;
private final X509Certificate[] mChain;
private final PrivateKey mPrivateKey;
/**
* @param alias Must not be null nor empty
* @throws MessagingException
* Indicates an error in retrieving the certificate for the alias
* (likely because the alias is invalid or the certificate was deleted)
*/
public KeyChainKeyManager(Context context, String alias) throws MessagingException {
mAlias = alias;
try {
mChain = fetchCertificateChain(context, alias);
mPrivateKey = fetchPrivateKey(context, alias);
} catch (KeyChainException e) {
// The certificate was possibly deleted. Notify user of error.
throw new CertificateValidationException(e.getMessage(), RetrievalFailure, alias);
} catch (InterruptedException e) {
throw new CertificateValidationException(e.getMessage(), RetrievalFailure, alias);
}
}
private X509Certificate[] fetchCertificateChain(Context context, String alias)
throws KeyChainException, InterruptedException, MessagingException {
X509Certificate[] chain = KeyChain.getCertificateChain(context, alias);
if (chain == null || chain.length == 0) {
throw new MessagingException("No certificate chain found for: " + alias);
}
try {
for (X509Certificate certificate : chain) {
certificate.checkValidity();
}
} catch (CertificateException e) {
throw new CertificateValidationException(e.getMessage(), Reason.Expired, alias);
}
return chain;
}
private PrivateKey fetchPrivateKey(Context context, String alias) throws KeyChainException,
InterruptedException, MessagingException {
PrivateKey privateKey = KeyChain.getPrivateKey(context, alias);
if (privateKey == null) {
throw new MessagingException("No private key found for: " + alias);
}
return privateKey;
}
@Override
public String chooseClientAlias(String[] keyTypes, Principal[] issuers, Socket socket) {
return chooseAlias(keyTypes, issuers);
}
@Override
public X509Certificate[] getCertificateChain(String alias) {
return (mAlias.equals(alias) ? mChain : null);
}
@Override
public PrivateKey getPrivateKey(String alias) {
return (mAlias.equals(alias) ? mPrivateKey : null);
}
@Override
public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) {
return chooseAlias(new String[] { keyType }, issuers);
}
@Override
public String[] getClientAliases(String keyType, Principal[] issuers) {
final String al = chooseAlias(new String[] { keyType }, issuers);
return (al == null ? null : new String[] { al });
}
@Override
public String[] getServerAliases(String keyType, Principal[] issuers) {
final String al = chooseAlias(new String[] { keyType }, issuers);
return (al == null ? null : new String[] { al });
}
@Override
public String chooseEngineClientAlias(String[] keyTypes, Principal[] issuers, SSLEngine engine) {
return chooseAlias(keyTypes, issuers);
}
@Override
public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) {
return chooseAlias(new String[] { keyType }, issuers);
}
private String chooseAlias(String[] keyTypes, Principal[] issuers) {
if (keyTypes == null || keyTypes.length == 0) {
return null;
}
final X509Certificate cert = mChain[0];
final String certKeyAlg = cert.getPublicKey().getAlgorithm();
final String certSigAlg = cert.getSigAlgName().toUpperCase(Locale.US);
for (String keyAlgorithm : keyTypes) {
if (keyAlgorithm == null) {
continue;
}
final String sigAlgorithm;
// handle cases like EC_EC and EC_RSA
int index = keyAlgorithm.indexOf('_');
if (index == -1) {
sigAlgorithm = null;
} else {
sigAlgorithm = keyAlgorithm.substring(index + 1);
keyAlgorithm = keyAlgorithm.substring(0, index);
}
// key algorithm does not match
if (!certKeyAlg.equals(keyAlgorithm)) {
continue;
}
/*
* TODO find a more reliable test for signature
* algorithm. Unfortunately value varies with
* provider. For example for "EC" it could be
* "SHA1WithECDSA" or simply "ECDSA".
*/
// sig algorithm does not match
if (sigAlgorithm != null && certSigAlg != null
&& !certSigAlg.contains(sigAlgorithm)) {
continue;
}
// no issuers to match
if (issuers == null || issuers.length == 0) {
return mAlias;
}
List<Principal> issuersList = Arrays.asList(issuers);
// check that a certificate in the chain was issued by one of the specified issuers
for (X509Certificate certFromChain : mChain) {
/*
* Note use of X500Principal from
* getIssuerX500Principal as opposed to Principal
* from getIssuerDN. Principal.equals test does
* not work in the case where
* xcertFromChain.getIssuerDN is a bouncycastle
* org.bouncycastle.jce.X509Principal.
*/
X500Principal issuerFromChain = certFromChain.getIssuerX500Principal();
if (issuersList.contains(issuerFromChain)) {
return mAlias;
}
}
Timber.w("Client certificate %s not issued by any of the requested issuers", mAlias);
return null;
}
Timber.w("Client certificate %s does not match any of the requested key types", mAlias);
return null;
}
}

View file

@ -0,0 +1,15 @@
package com.fsck.k9.helper
import android.app.AlarmManager
import android.content.Context
import com.fsck.k9.mail.ssl.KeyStoreDirectoryProvider
import org.koin.dsl.module
val helperModule = module {
single { ClipboardManager(get()) }
single { MessageHelper(resourceProvider = get(), contactRepository = get()) }
factory<KeyStoreDirectoryProvider> { AndroidKeyStoreDirectoryProvider(context = get()) }
factory { get<Context>().getSystemService(Context.ALARM_SERVICE) as AlarmManager }
single { AlarmManagerCompat(alarmManager = get()) }
factory<ContactNameProvider> { RealContactNameProvider(contactRepository = get()) }
}

View file

@ -0,0 +1,70 @@
package com.fsck.k9.helper;
import android.net.Uri;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.fsck.k9.mail.Address;
import com.fsck.k9.mail.Message;
/**
* Intended to cover:
*
* RFC 2369
* The Use of URLs as Meta-Syntax for Core Mail List Commands
* and their Transport through Message Header Fields
* https://www.ietf.org/rfc/rfc2369.txt
*
* This is the following fields:
*
* List-Help
* List-Subscribe
* List-Unsubscribe
* List-Post
* List-Owner
* List-Archive
*
* Currently only provides a utility method for List-Post
**/
public class ListHeaders {
public static final String LIST_POST_HEADER = "List-Post";
private static final Pattern MAILTO_CONTAINER_PATTERN = Pattern.compile("<(mailto:.+)>");
public static Address[] getListPostAddresses(Message message) {
String[] headerValues = message.getHeader(LIST_POST_HEADER);
if (headerValues.length < 1) {
return new Address[0];
}
List<Address> listPostAddresses = new ArrayList<>();
for (String headerValue : headerValues) {
Address address = extractAddress(headerValue);
if (address != null) {
listPostAddresses.add(address);
}
}
return listPostAddresses.toArray(new Address[listPostAddresses.size()]);
}
private static Address extractAddress(String headerValue) {
if (headerValue == null || headerValue.isEmpty()) {
return null;
}
Matcher matcher = MAILTO_CONTAINER_PATTERN.matcher(headerValue);
if (!matcher.find()) {
return null;
}
Uri mailToUri = Uri.parse(matcher.group(1));
Address[] emailAddress = MailTo.parse(mailToUri).getTo();
return emailAddress.length >= 1 ? emailAddress[0] : null;
}
}

View file

@ -0,0 +1,55 @@
package com.fsck.k9.helper
import android.net.Uri
import com.fsck.k9.mail.Message
import java.util.regex.Pattern
object ListUnsubscribeHelper {
private const val LIST_UNSUBSCRIBE_HEADER = "List-Unsubscribe"
private val MAILTO_CONTAINER_PATTERN = Pattern.compile("<(mailto:.+?)>")
private val HTTPS_CONTAINER_PATTERN = Pattern.compile("<(https:.+?)>")
// As K-9 Mail is an email client, we prefer a mailto: unsubscribe method
// but if none is found, a https URL is acceptable too
fun getPreferredListUnsubscribeUri(message: Message): UnsubscribeUri? {
val headerValues = message.getHeader(LIST_UNSUBSCRIBE_HEADER)
if (headerValues.isEmpty()) {
return null
}
val listUnsubscribeUris = mutableListOf<Uri>()
for (headerValue in headerValues) {
val uri = extractUri(headerValue) ?: continue
if (uri.scheme == "mailto") {
return MailtoUnsubscribeUri(uri)
}
// If we got here it must be HTTPS
listUnsubscribeUris.add(uri)
}
if (listUnsubscribeUris.isNotEmpty()) {
return HttpsUnsubscribeUri(listUnsubscribeUris[0])
}
return null
}
private fun extractUri(headerValue: String?): Uri? {
if (headerValue == null || headerValue.isEmpty()) {
return null
}
var matcher = MAILTO_CONTAINER_PATTERN.matcher(headerValue)
if (matcher.find()) {
return Uri.parse(matcher.group(1))
}
matcher = HTTPS_CONTAINER_PATTERN.matcher(headerValue)
if (matcher.find()) {
return Uri.parse(matcher.group(1))
}
return null
}
}

View file

@ -0,0 +1,165 @@
package com.fsck.k9.helper;
import android.net.Uri;
import com.fsck.k9.mail.Address;
import com.fsck.k9.mail.internet.MessageIdParser;
import com.fsck.k9.mail.internet.MimeHeaderParserException;
import timber.log.Timber;
import java.util.ArrayList;
import java.util.List;
public final class MailTo {
private static final String MAILTO_SCHEME = "mailto";
private static final String TO = "to";
private static final String IN_REPLY_TO = "in-reply-to";
private static final String BODY = "body";
private static final String CC = "cc";
private static final String BCC = "bcc";
private static final String SUBJECT = "subject";
private static final Address[] EMPTY_ADDRESS_LIST = new Address[0];
private final Address[] toAddresses;
private final Address[] ccAddresses;
private final Address[] bccAddresses;
private final String inReplyToMessageId;
private final String subject;
private final String body;
public static boolean isMailTo(Uri uri) {
return uri != null && MAILTO_SCHEME.equals(uri.getScheme());
}
public static MailTo parse(Uri uri) throws NullPointerException, IllegalArgumentException {
if (uri == null || uri.toString() == null) {
throw new NullPointerException("Argument 'uri' must not be null");
}
if (!isMailTo(uri)) {
throw new IllegalArgumentException("Not a mailto scheme");
}
String schemaSpecific = uri.getSchemeSpecificPart();
int end = schemaSpecific.indexOf('?');
if (end == -1) {
end = schemaSpecific.length();
}
CaseInsensitiveParamWrapper params =
new CaseInsensitiveParamWrapper(Uri.parse("foo://bar?" + uri.getEncodedQuery()));
// Extract the recipient's email address from the mailto URI if there's one.
String recipient = Uri.decode(schemaSpecific.substring(0, end));
List<String> toList = params.getQueryParameters(TO);
if (recipient.length() != 0) {
toList.add(0, recipient);
}
List<String> ccList = params.getQueryParameters(CC);
List<String> bccList = params.getQueryParameters(BCC);
Address[] toAddresses = toAddressArray(toList);
Address[] ccAddresses = toAddressArray(ccList);
Address[] bccAddresses = toAddressArray(bccList);
String subject = getFirstParameterValue(params, SUBJECT);
String body = getFirstParameterValue(params, BODY);
String inReplyTo = getFirstParameterValue(params, IN_REPLY_TO);
String inReplyToMessageId = null;
if (inReplyTo != null) {
try {
List<String> inReplyToMessageIds = MessageIdParser.parseList(inReplyTo);
inReplyToMessageId = inReplyToMessageIds.get(0);
} catch (MimeHeaderParserException e) {
Timber.w(e, "Ignoring invalid in-reply-to value within the mailto: link.");
}
}
return new MailTo(toAddresses, ccAddresses, bccAddresses, inReplyToMessageId, subject, body);
}
private static String getFirstParameterValue(CaseInsensitiveParamWrapper params, String paramName) {
List<String> paramValues = params.getQueryParameters(paramName);
return (paramValues.isEmpty()) ? null : paramValues.get(0);
}
private static Address[] toAddressArray(List<String> recipients) {
if (recipients.isEmpty()) {
return EMPTY_ADDRESS_LIST;
}
String addressList = toCommaSeparatedString(recipients);
return Address.parse(addressList);
}
private static String toCommaSeparatedString(List<String> list) {
StringBuilder stringBuilder = new StringBuilder();
for (String item : list) {
stringBuilder.append(item).append(',');
}
stringBuilder.setLength(stringBuilder.length() - 1);
return stringBuilder.toString();
}
private MailTo(Address[] toAddresses, Address[] ccAddresses, Address[] bccAddresses, String inReplyToMessageId,
String subject, String body) {
this.toAddresses = toAddresses;
this.ccAddresses = ccAddresses;
this.bccAddresses = bccAddresses;
this.inReplyToMessageId = inReplyToMessageId;
this.subject = subject;
this.body = body;
}
public Address[] getTo() {
return toAddresses;
}
public Address[] getCc() {
return ccAddresses;
}
public Address[] getBcc() {
return bccAddresses;
}
public String getSubject() {
return subject;
}
public String getBody() {
return body;
}
public String getInReplyTo() { return inReplyToMessageId; }
static class CaseInsensitiveParamWrapper {
private final Uri uri;
public CaseInsensitiveParamWrapper(Uri uri) {
this.uri = uri;
}
public List<String> getQueryParameters(String key) {
List<String> params = new ArrayList<>();
for (String paramName : uri.getQueryParameterNames()) {
if (paramName.equalsIgnoreCase(key)) {
params.addAll(uri.getQueryParameters(paramName));
}
}
return params;
}
}
}

View file

@ -0,0 +1,131 @@
package com.fsck.k9.helper
import android.text.Spannable
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.TextUtils
import android.text.style.ForegroundColorSpan
import app.k9mail.core.android.common.contact.ContactRepository
import app.k9mail.core.common.mail.EmailAddress
import com.fsck.k9.CoreResourceProvider
import com.fsck.k9.K9.contactNameColor
import com.fsck.k9.K9.isChangeContactNameColor
import com.fsck.k9.K9.isShowContactName
import com.fsck.k9.K9.isShowCorrespondentNames
import com.fsck.k9.mail.Address
import java.util.regex.Pattern
class MessageHelper(
private val resourceProvider: CoreResourceProvider,
private val contactRepository: ContactRepository,
) {
fun getSenderDisplayName(address: Address?): CharSequence {
if (address == null) {
return resourceProvider.contactUnknownSender()
}
val repository = if (isShowContactName) contactRepository else null
return toFriendly(address, repository)
}
fun getRecipientDisplayNames(addresses: Array<Address>?): CharSequence {
if (addresses == null || addresses.isEmpty()) {
return resourceProvider.contactUnknownRecipient()
}
val repository = if (isShowContactName) contactRepository else null
val recipients = toFriendly(addresses, repository)
return SpannableStringBuilder(resourceProvider.contactDisplayNamePrefix()).append(recipients)
}
companion object {
/**
* If the number of addresses exceeds this value the addresses aren't
* resolved to the names of Android contacts.
*
* TODO: This number was chosen arbitrarily and should be determined by performance tests.
*
* @see .toFriendly
*/
private const val TOO_MANY_ADDRESSES = 50
private val SPOOF_ADDRESS_PATTERN = Pattern.compile("[^(]@")
/**
* Returns the name of the contact this email address belongs to if
* the [contacts][Contacts] parameter is not `null` and a
* contact is found. Otherwise the personal portion of the [Address]
* is returned. If that isn't available either, the email address is
* returned.
*
* @param address An [com.fsck.k9.mail.Address]
* @param contacts A [Contacts] instance or `null`.
* @return A "friendly" name for this [Address].
*/
fun toFriendly(address: Address, contactRepository: ContactRepository?): CharSequence {
return toFriendly(
address,
contactRepository,
isShowCorrespondentNames,
isChangeContactNameColor,
contactNameColor,
)
}
fun toFriendly(addresses: Array<Address>?, contactRepository: ContactRepository?): CharSequence? {
var repository = contactRepository
if (addresses == null) {
return null
}
if (addresses.size >= TOO_MANY_ADDRESSES) {
// Don't look up contacts if the number of addresses is very high.
repository = null
}
val stringBuilder = SpannableStringBuilder()
for (i in addresses.indices) {
stringBuilder.append(toFriendly(addresses[i], repository))
if (i < addresses.size - 1) {
stringBuilder.append(',')
}
}
return stringBuilder
}
/* package, for testing */
@JvmStatic
fun toFriendly(
address: Address,
contactRepository: ContactRepository?,
showCorrespondentNames: Boolean,
changeContactNameColor: Boolean,
contactNameColor: Int,
): CharSequence {
if (!showCorrespondentNames) {
return address.address
} else if (contactRepository != null) {
val name = contactRepository.getContactFor(EmailAddress(address.address))?.name
if (name != null) {
return if (changeContactNameColor) {
val coloredName = SpannableString(name)
coloredName.setSpan(
ForegroundColorSpan(contactNameColor),
0,
coloredName.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE,
)
coloredName
} else {
name
}
}
}
return if (!TextUtils.isEmpty(address.personal) && !isSpoofAddress(address.personal)) {
address.personal
} else {
address.address
}
}
private fun isSpoofAddress(displayName: String): Boolean {
return displayName.contains("@") && SPOOF_ADDRESS_PATTERN.matcher(displayName).find()
}
}
}

View file

@ -0,0 +1,925 @@
package com.fsck.k9.helper;
import java.util.Locale;
import org.jetbrains.annotations.NotNull;
public class MimeTypeUtil {
public static final String DEFAULT_ATTACHMENT_MIME_TYPE = "application/octet-stream";
public static final String K9_SETTINGS_MIME_TYPE = "application/x-k9settings";
/*
* http://www.w3schools.com/media/media_mimeref.asp
* +
* http://www.stdicon.com/mimetypes
*/
static final String[][] MIME_TYPE_BY_EXTENSION_MAP = new String[][] {
//* Do not delete the next three lines
{ "", DEFAULT_ATTACHMENT_MIME_TYPE },
{ "k9s", K9_SETTINGS_MIME_TYPE },
{ "txt", "text/plain" },
//* Do not delete the previous three lines
{ "123", "application/vnd.lotus-1-2-3" },
{ "323", "text/h323" },
{ "3dml", "text/vnd.in3d.3dml" },
{ "3g2", "video/3gpp2" },
{ "3gp", "video/3gpp" },
{ "aab", "application/x-authorware-bin" },
{ "aac", "audio/x-aac" },
{ "aam", "application/x-authorware-map" },
{ "a", "application/octet-stream" },
{ "aas", "application/x-authorware-seg" },
{ "abw", "application/x-abiword" },
{ "acc", "application/vnd.americandynamics.acc" },
{ "ace", "application/x-ace-compressed" },
{ "acu", "application/vnd.acucobol" },
{ "acutc", "application/vnd.acucorp" },
{ "acx", "application/internet-property-stream" },
{ "adp", "audio/adpcm" },
{ "aep", "application/vnd.audiograph" },
{ "afm", "application/x-font-type1" },
{ "afp", "application/vnd.ibm.modcap" },
{ "ai", "application/postscript" },
{ "aif", "audio/x-aiff" },
{ "aifc", "audio/x-aiff" },
{ "aiff", "audio/x-aiff" },
{ "air", "application/vnd.adobe.air-application-installer-package+zip" },
{ "ami", "application/vnd.amiga.ami" },
{ "apk", "application/vnd.android.package-archive" },
{ "application", "application/x-ms-application" },
{ "apr", "application/vnd.lotus-approach" },
{ "asc", "application/pgp-signature" },
{ "asf", "video/x-ms-asf" },
{ "asm", "text/x-asm" },
{ "aso", "application/vnd.accpac.simply.aso" },
{ "asr", "video/x-ms-asf" },
{ "asx", "video/x-ms-asf" },
{ "atc", "application/vnd.acucorp" },
{ "atom", "application/atom+xml" },
{ "atomcat", "application/atomcat+xml" },
{ "atomsvc", "application/atomsvc+xml" },
{ "atx", "application/vnd.antix.game-component" },
{ "au", "audio/basic" },
{ "avi", "video/x-msvideo" },
{ "aw", "application/applixware" },
{ "axs", "application/olescript" },
{ "azf", "application/vnd.airzip.filesecure.azf" },
{ "azs", "application/vnd.airzip.filesecure.azs" },
{ "azw", "application/vnd.amazon.ebook" },
{ "bas", "text/plain" },
{ "bat", "application/x-msdownload" },
{ "bcpio", "application/x-bcpio" },
{ "bdf", "application/x-font-bdf" },
{ "bdm", "application/vnd.syncml.dm+wbxml" },
{ "bh2", "application/vnd.fujitsu.oasysprs" },
{ "bin", "application/octet-stream" },
{ "bmi", "application/vnd.bmi" },
{ "bmp", "image/bmp" },
{ "book", "application/vnd.framemaker" },
{ "box", "application/vnd.previewsystems.box" },
{ "boz", "application/x-bzip2" },
{ "bpk", "application/octet-stream" },
{ "btif", "image/prs.btif" },
{ "bz2", "application/x-bzip2" },
{ "bz", "application/x-bzip" },
{ "c4d", "application/vnd.clonk.c4group" },
{ "c4f", "application/vnd.clonk.c4group" },
{ "c4g", "application/vnd.clonk.c4group" },
{ "c4p", "application/vnd.clonk.c4group" },
{ "c4u", "application/vnd.clonk.c4group" },
{ "cab", "application/vnd.ms-cab-compressed" },
{ "car", "application/vnd.curl.car" },
{ "cat", "application/vnd.ms-pki.seccat" },
{ "cct", "application/x-director" },
{ "cc", "text/x-c" },
{ "ccxml", "application/ccxml+xml" },
{ "cdbcmsg", "application/vnd.contact.cmsg" },
{ "cdf", "application/x-cdf" },
{ "cdkey", "application/vnd.mediastation.cdkey" },
{ "cdx", "chemical/x-cdx" },
{ "cdxml", "application/vnd.chemdraw+xml" },
{ "cdy", "application/vnd.cinderella" },
{ "cer", "application/x-x509-ca-cert" },
{ "cgm", "image/cgm" },
{ "chat", "application/x-chat" },
{ "chm", "application/vnd.ms-htmlhelp" },
{ "chrt", "application/vnd.kde.kchart" },
{ "cif", "chemical/x-cif" },
{ "cii", "application/vnd.anser-web-certificate-issue-initiation" },
{ "cla", "application/vnd.claymore" },
{ "class", "application/java-vm" },
{ "clkk", "application/vnd.crick.clicker.keyboard" },
{ "clkp", "application/vnd.crick.clicker.palette" },
{ "clkt", "application/vnd.crick.clicker.template" },
{ "clkw", "application/vnd.crick.clicker.wordbank" },
{ "clkx", "application/vnd.crick.clicker" },
{ "clp", "application/x-msclip" },
{ "cmc", "application/vnd.cosmocaller" },
{ "cmdf", "chemical/x-cmdf" },
{ "cml", "chemical/x-cml" },
{ "cmp", "application/vnd.yellowriver-custom-menu" },
{ "cmx", "image/x-cmx" },
{ "cod", "application/vnd.rim.cod" },
{ "com", "application/x-msdownload" },
{ "conf", "text/plain" },
{ "cpio", "application/x-cpio" },
{ "cpp", "text/x-c" },
{ "cpt", "application/mac-compactpro" },
{ "crd", "application/x-mscardfile" },
{ "crl", "application/pkix-crl" },
{ "crt", "application/x-x509-ca-cert" },
{ "csh", "application/x-csh" },
{ "csml", "chemical/x-csml" },
{ "csp", "application/vnd.commonspace" },
{ "css", "text/css" },
{ "cst", "application/x-director" },
{ "csv", "text/csv" },
{ "c", "text/plain" },
{ "cu", "application/cu-seeme" },
{ "curl", "text/vnd.curl" },
{ "cww", "application/prs.cww" },
{ "cxt", "application/x-director" },
{ "cxx", "text/x-c" },
{ "daf", "application/vnd.mobius.daf" },
{ "dataless", "application/vnd.fdsn.seed" },
{ "davmount", "application/davmount+xml" },
{ "dcr", "application/x-director" },
{ "dcurl", "text/vnd.curl.dcurl" },
{ "dd2", "application/vnd.oma.dd2+xml" },
{ "ddd", "application/vnd.fujixerox.ddd" },
{ "deb", "application/x-debian-package" },
{ "def", "text/plain" },
{ "deploy", "application/octet-stream" },
{ "der", "application/x-x509-ca-cert" },
{ "dfac", "application/vnd.dreamfactory" },
{ "dic", "text/x-c" },
{ "diff", "text/plain" },
{ "dir", "application/x-director" },
{ "dis", "application/vnd.mobius.dis" },
{ "dist", "application/octet-stream" },
{ "distz", "application/octet-stream" },
{ "djv", "image/vnd.djvu" },
{ "djvu", "image/vnd.djvu" },
{ "dll", "application/x-msdownload" },
{ "dmg", "application/octet-stream" },
{ "dms", "application/octet-stream" },
{ "dna", "application/vnd.dna" },
{ "doc", "application/msword" },
{ "docm", "application/vnd.ms-word.document.macroenabled.12" },
{ "docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" },
{ "dot", "application/msword" },
{ "dotm", "application/vnd.ms-word.template.macroenabled.12" },
{ "dotx", "application/vnd.openxmlformats-officedocument.wordprocessingml.template" },
{ "dp", "application/vnd.osgi.dp" },
{ "dpg", "application/vnd.dpgraph" },
{ "dsc", "text/prs.lines.tag" },
{ "dtb", "application/x-dtbook+xml" },
{ "dtd", "application/xml-dtd" },
{ "dts", "audio/vnd.dts" },
{ "dtshd", "audio/vnd.dts.hd" },
{ "dump", "application/octet-stream" },
{ "dvi", "application/x-dvi" },
{ "dwf", "model/vnd.dwf" },
{ "dwg", "image/vnd.dwg" },
{ "dxf", "image/vnd.dxf" },
{ "dxp", "application/vnd.spotfire.dxp" },
{ "dxr", "application/x-director" },
{ "ecelp4800", "audio/vnd.nuera.ecelp4800" },
{ "ecelp7470", "audio/vnd.nuera.ecelp7470" },
{ "ecelp9600", "audio/vnd.nuera.ecelp9600" },
{ "ecma", "application/ecmascript" },
{ "edm", "application/vnd.novadigm.edm" },
{ "edx", "application/vnd.novadigm.edx" },
{ "efif", "application/vnd.picsel" },
{ "ei6", "application/vnd.pg.osasli" },
{ "elc", "application/octet-stream" },
{ "eml", "message/rfc822" },
{ "emma", "application/emma+xml" },
{ "eol", "audio/vnd.digital-winds" },
{ "eot", "application/vnd.ms-fontobject" },
{ "eps", "application/postscript" },
{ "epub", "application/epub+zip" },
{ "es3", "application/vnd.eszigno3+xml" },
{ "esf", "application/vnd.epson.esf" },
{ "espass", "application/vnd.espass-espass+zip" },
{ "et3", "application/vnd.eszigno3+xml" },
{ "etx", "text/x-setext" },
{ "evy", "application/envoy" },
{ "exe", "application/octet-stream" },
{ "ext", "application/vnd.novadigm.ext" },
{ "ez2", "application/vnd.ezpix-album" },
{ "ez3", "application/vnd.ezpix-package" },
{ "ez", "application/andrew-inset" },
{ "f4v", "video/x-f4v" },
{ "f77", "text/x-fortran" },
{ "f90", "text/x-fortran" },
{ "fbs", "image/vnd.fastbidsheet" },
{ "fdf", "application/vnd.fdf" },
{ "fe_launch", "application/vnd.denovo.fcselayout-link" },
{ "fg5", "application/vnd.fujitsu.oasysgp" },
{ "fgd", "application/x-director" },
{ "fh4", "image/x-freehand" },
{ "fh5", "image/x-freehand" },
{ "fh7", "image/x-freehand" },
{ "fhc", "image/x-freehand" },
{ "fh", "image/x-freehand" },
{ "fif", "application/fractals" },
{ "fig", "application/x-xfig" },
{ "fli", "video/x-fli" },
{ "flo", "application/vnd.micrografx.flo" },
{ "flr", "x-world/x-vrml" },
{ "flv", "video/x-flv" },
{ "flw", "application/vnd.kde.kivio" },
{ "flx", "text/vnd.fmi.flexstor" },
{ "fly", "text/vnd.fly" },
{ "fm", "application/vnd.framemaker" },
{ "fnc", "application/vnd.frogans.fnc" },
{ "for", "text/x-fortran" },
{ "fpx", "image/vnd.fpx" },
{ "frame", "application/vnd.framemaker" },
{ "fsc", "application/vnd.fsc.weblaunch" },
{ "fst", "image/vnd.fst" },
{ "ftc", "application/vnd.fluxtime.clip" },
{ "f", "text/x-fortran" },
{ "fti", "application/vnd.anser-web-funds-transfer-initiation" },
{ "fvt", "video/vnd.fvt" },
{ "fzs", "application/vnd.fuzzysheet" },
{ "g3", "image/g3fax" },
{ "gac", "application/vnd.groove-account" },
{ "gdl", "model/vnd.gdl" },
{ "geo", "application/vnd.dynageo" },
{ "gex", "application/vnd.geometry-explorer" },
{ "ggb", "application/vnd.geogebra.file" },
{ "ggt", "application/vnd.geogebra.tool" },
{ "ghf", "application/vnd.groove-help" },
{ "gif", "image/gif" },
{ "gim", "application/vnd.groove-identity-message" },
{ "gmx", "application/vnd.gmx" },
{ "gnumeric", "application/x-gnumeric" },
{ "gph", "application/vnd.flographit" },
{ "gqf", "application/vnd.grafeq" },
{ "gqs", "application/vnd.grafeq" },
{ "gram", "application/srgs" },
{ "gre", "application/vnd.geometry-explorer" },
{ "grv", "application/vnd.groove-injector" },
{ "grxml", "application/srgs+xml" },
{ "gsf", "application/x-font-ghostscript" },
{ "gtar", "application/x-gtar" },
{ "gtm", "application/vnd.groove-tool-message" },
{ "gtw", "model/vnd.gtw" },
{ "gv", "text/vnd.graphviz" },
{ "gz", "application/x-gzip" },
{ "h261", "video/h261" },
{ "h263", "video/h263" },
{ "h264", "video/h264" },
{ "hbci", "application/vnd.hbci" },
{ "hdf", "application/x-hdf" },
{ "hh", "text/x-c" },
{ "hlp", "application/winhlp" },
{ "hpgl", "application/vnd.hp-hpgl" },
{ "hpid", "application/vnd.hp-hpid" },
{ "hps", "application/vnd.hp-hps" },
{ "hqx", "application/mac-binhex40" },
{ "hta", "application/hta" },
{ "htc", "text/x-component" },
{ "h", "text/plain" },
{ "htke", "application/vnd.kenameaapp" },
{ "html", "text/html" },
{ "htm", "text/html" },
{ "htt", "text/webviewhtml" },
{ "hvd", "application/vnd.yamaha.hv-dic" },
{ "hvp", "application/vnd.yamaha.hv-voice" },
{ "hvs", "application/vnd.yamaha.hv-script" },
{ "icc", "application/vnd.iccprofile" },
{ "ice", "x-conference/x-cooltalk" },
{ "icm", "application/vnd.iccprofile" },
{ "ico", "image/x-icon" },
{ "ics", "text/calendar" },
{ "ief", "image/ief" },
{ "ifb", "text/calendar" },
{ "ifm", "application/vnd.shana.informed.formdata" },
{ "iges", "model/iges" },
{ "igl", "application/vnd.igloader" },
{ "igs", "model/iges" },
{ "igx", "application/vnd.micrografx.igx" },
{ "iif", "application/vnd.shana.informed.interchange" },
{ "iii", "application/x-iphone" },
{ "imp", "application/vnd.accpac.simply.imp" },
{ "ims", "application/vnd.ms-ims" },
{ "ins", "application/x-internet-signup" },
{ "in", "text/plain" },
{ "ipk", "application/vnd.shana.informed.package" },
{ "irm", "application/vnd.ibm.rights-management" },
{ "irp", "application/vnd.irepository.package+xml" },
{ "iso", "application/octet-stream" },
{ "isp", "application/x-internet-signup" },
{ "itp", "application/vnd.shana.informed.formtemplate" },
{ "ivp", "application/vnd.immervision-ivp" },
{ "ivu", "application/vnd.immervision-ivu" },
{ "jad", "text/vnd.sun.j2me.app-descriptor" },
{ "jam", "application/vnd.jam" },
{ "jar", "application/java-archive" },
{ "java", "text/x-java-source" },
{ "jfif", "image/pipeg" },
{ "jisp", "application/vnd.jisp" },
{ "jlt", "application/vnd.hp-jlyt" },
{ "jnlp", "application/x-java-jnlp-file" },
{ "joda", "application/vnd.joost.joda-archive" },
{ "jpeg", "image/jpeg" },
{ "jpe", "image/jpeg" },
{ "jpg", "image/jpeg" },
{ "jpgm", "video/jpm" },
{ "jpgv", "video/jpeg" },
{ "jpm", "video/jpm" },
{ "js", "application/x-javascript" },
{ "json", "application/json" },
{ "kar", "audio/midi" },
{ "karbon", "application/vnd.kde.karbon" },
{ "kfo", "application/vnd.kde.kformula" },
{ "kia", "application/vnd.kidspiration" },
{ "kil", "application/x-killustrator" },
{ "kml", "application/vnd.google-earth.kml+xml" },
{ "kmz", "application/vnd.google-earth.kmz" },
{ "kne", "application/vnd.kinar" },
{ "knp", "application/vnd.kinar" },
{ "kon", "application/vnd.kde.kontour" },
{ "kpr", "application/vnd.kde.kpresenter" },
{ "kpt", "application/vnd.kde.kpresenter" },
{ "ksh", "text/plain" },
{ "ksp", "application/vnd.kde.kspread" },
{ "ktr", "application/vnd.kahootz" },
{ "ktz", "application/vnd.kahootz" },
{ "kwd", "application/vnd.kde.kword" },
{ "kwt", "application/vnd.kde.kword" },
{ "latex", "application/x-latex" },
{ "lbd", "application/vnd.llamagraphics.life-balance.desktop" },
{ "lbe", "application/vnd.llamagraphics.life-balance.exchange+xml" },
{ "les", "application/vnd.hhe.lesson-player" },
{ "lha", "application/octet-stream" },
{ "link66", "application/vnd.route66.link66+xml" },
{ "list3820", "application/vnd.ibm.modcap" },
{ "listafp", "application/vnd.ibm.modcap" },
{ "list", "text/plain" },
{ "log", "text/plain" },
{ "lostxml", "application/lost+xml" },
{ "lrf", "application/octet-stream" },
{ "lrm", "application/vnd.ms-lrm" },
{ "lsf", "video/x-la-asf" },
{ "lsx", "video/x-la-asf" },
{ "ltf", "application/vnd.frogans.ltf" },
{ "lvp", "audio/vnd.lucent.voice" },
{ "lwp", "application/vnd.lotus-wordpro" },
{ "lzh", "application/octet-stream" },
{ "m13", "application/x-msmediaview" },
{ "m14", "application/x-msmediaview" },
{ "m1v", "video/mpeg" },
{ "m2a", "audio/mpeg" },
{ "m2v", "video/mpeg" },
{ "m3a", "audio/mpeg" },
{ "m3u", "audio/x-mpegurl" },
{ "m4u", "video/vnd.mpegurl" },
{ "m4v", "video/x-m4v" },
{ "ma", "application/mathematica" },
{ "mag", "application/vnd.ecowin.chart" },
{ "maker", "application/vnd.framemaker" },
{ "man", "text/troff" },
{ "mathml", "application/mathml+xml" },
{ "mb", "application/mathematica" },
{ "mbk", "application/vnd.mobius.mbk" },
{ "mbox", "application/mbox" },
{ "mc1", "application/vnd.medcalcdata" },
{ "mcd", "application/vnd.mcd" },
{ "mcurl", "text/vnd.curl.mcurl" },
{ "mdb", "application/x-msaccess" },
{ "mdi", "image/vnd.ms-modi" },
{ "mesh", "model/mesh" },
{ "me", "text/troff" },
{ "mfm", "application/vnd.mfmp" },
{ "mgz", "application/vnd.proteus.magazine" },
{ "mht", "message/rfc822" },
{ "mhtml", "message/rfc822" },
{ "mid", "audio/midi" },
{ "midi", "audio/midi" },
{ "mif", "application/vnd.mif" },
{ "mime", "message/rfc822" },
{ "mj2", "video/mj2" },
{ "mjp2", "video/mj2" },
{ "mlp", "application/vnd.dolby.mlp" },
{ "mmd", "application/vnd.chipnuts.karaoke-mmd" },
{ "mmf", "application/vnd.smaf" },
{ "mmr", "image/vnd.fujixerox.edmics-mmr" },
{ "mny", "application/x-msmoney" },
{ "mobi", "application/x-mobipocket-ebook" },
{ "movie", "video/x-sgi-movie" },
{ "mov", "video/quicktime" },
{ "mp2a", "audio/mpeg" },
{ "mp2", "video/mpeg" },
{ "mp3", "audio/mpeg" },
{ "mp4a", "audio/mp4" },
{ "mp4s", "application/mp4" },
{ "mp4", "video/mp4" },
{ "mp4v", "video/mp4" },
{ "mpa", "video/mpeg" },
{ "mpc", "application/vnd.mophun.certificate" },
{ "mpeg", "video/mpeg" },
{ "mpe", "video/mpeg" },
{ "mpg4", "video/mp4" },
{ "mpga", "audio/mpeg" },
{ "mpg", "video/mpeg" },
{ "mpkg", "application/vnd.apple.installer+xml" },
{ "mpm", "application/vnd.blueice.multipass" },
{ "mpn", "application/vnd.mophun.application" },
{ "mpp", "application/vnd.ms-project" },
{ "mpt", "application/vnd.ms-project" },
{ "mpv2", "video/mpeg" },
{ "mpy", "application/vnd.ibm.minipay" },
{ "mqy", "application/vnd.mobius.mqy" },
{ "mrc", "application/marc" },
{ "mscml", "application/mediaservercontrol+xml" },
{ "mseed", "application/vnd.fdsn.mseed" },
{ "mseq", "application/vnd.mseq" },
{ "msf", "application/vnd.epson.msf" },
{ "msh", "model/mesh" },
{ "msi", "application/x-msdownload" },
{ "ms", "text/troff" },
{ "msty", "application/vnd.muvee.style" },
{ "mts", "model/vnd.mts" },
{ "mus", "application/vnd.musician" },
{ "musicxml", "application/vnd.recordare.musicxml+xml" },
{ "mvb", "application/x-msmediaview" },
{ "mxf", "application/mxf" },
{ "mxl", "application/vnd.recordare.musicxml" },
{ "mxml", "application/xv+xml" },
{ "mxs", "application/vnd.triscape.mxs" },
{ "mxu", "video/vnd.mpegurl" },
{ "nb", "application/mathematica" },
{ "nc", "application/x-netcdf" },
{ "ncx", "application/x-dtbncx+xml" },
{ "n-gage", "application/vnd.nokia.n-gage.symbian.install" },
{ "ngdat", "application/vnd.nokia.n-gage.data" },
{ "nlu", "application/vnd.neurolanguage.nlu" },
{ "nml", "application/vnd.enliven" },
{ "nnd", "application/vnd.noblenet-directory" },
{ "nns", "application/vnd.noblenet-sealer" },
{ "nnw", "application/vnd.noblenet-web" },
{ "npx", "image/vnd.net-fpx" },
{ "nsf", "application/vnd.lotus-notes" },
{ "nws", "message/rfc822" },
{ "oa2", "application/vnd.fujitsu.oasys2" },
{ "oa3", "application/vnd.fujitsu.oasys3" },
{ "o", "application/octet-stream" },
{ "oas", "application/vnd.fujitsu.oasys" },
{ "obd", "application/x-msbinder" },
{ "obj", "application/octet-stream" },
{ "oda", "application/oda" },
{ "odb", "application/vnd.oasis.opendocument.database" },
{ "odc", "application/vnd.oasis.opendocument.chart" },
{ "odf", "application/vnd.oasis.opendocument.formula" },
{ "odft", "application/vnd.oasis.opendocument.formula-template" },
{ "odg", "application/vnd.oasis.opendocument.graphics" },
{ "odi", "application/vnd.oasis.opendocument.image" },
{ "odp", "application/vnd.oasis.opendocument.presentation" },
{ "ods", "application/vnd.oasis.opendocument.spreadsheet" },
{ "odt", "application/vnd.oasis.opendocument.text" },
{ "oga", "audio/ogg" },
{ "ogg", "audio/ogg" },
{ "ogv", "video/ogg" },
{ "ogx", "application/ogg" },
{ "onepkg", "application/onenote" },
{ "onetmp", "application/onenote" },
{ "onetoc2", "application/onenote" },
{ "onetoc", "application/onenote" },
{ "opf", "application/oebps-package+xml" },
{ "oprc", "application/vnd.palm" },
{ "org", "application/vnd.lotus-organizer" },
{ "osf", "application/vnd.yamaha.openscoreformat" },
{ "osfpvg", "application/vnd.yamaha.openscoreformat.osfpvg+xml" },
{ "otc", "application/vnd.oasis.opendocument.chart-template" },
{ "otf", "application/x-font-otf" },
{ "otg", "application/vnd.oasis.opendocument.graphics-template" },
{ "oth", "application/vnd.oasis.opendocument.text-web" },
{ "oti", "application/vnd.oasis.opendocument.image-template" },
{ "otm", "application/vnd.oasis.opendocument.text-master" },
{ "otp", "application/vnd.oasis.opendocument.presentation-template" },
{ "ots", "application/vnd.oasis.opendocument.spreadsheet-template" },
{ "ott", "application/vnd.oasis.opendocument.text-template" },
{ "oxt", "application/vnd.openofficeorg.extension" },
{ "p10", "application/pkcs10" },
{ "p12", "application/x-pkcs12" },
{ "p7b", "application/x-pkcs7-certificates" },
{ "p7c", "application/x-pkcs7-mime" },
{ "p7m", "application/x-pkcs7-mime" },
{ "p7r", "application/x-pkcs7-certreqresp" },
{ "p7s", "application/x-pkcs7-signature" },
{ "pas", "text/x-pascal" },
{ "pbd", "application/vnd.powerbuilder6" },
{ "pbm", "image/x-portable-bitmap" },
{ "pcf", "application/x-font-pcf" },
{ "pcl", "application/vnd.hp-pcl" },
{ "pclxl", "application/vnd.hp-pclxl" },
{ "pct", "image/x-pict" },
{ "pcurl", "application/vnd.curl.pcurl" },
{ "pcx", "image/x-pcx" },
{ "pdb", "application/vnd.palm" },
{ "pdf", "application/pdf" },
{ "pfa", "application/x-font-type1" },
{ "pfb", "application/x-font-type1" },
{ "pfm", "application/x-font-type1" },
{ "pfr", "application/font-tdpfr" },
{ "pfx", "application/x-pkcs12" },
{ "pgm", "image/x-portable-graymap" },
{ "pgn", "application/x-chess-pgn" },
{ "pgp", "application/pgp-encrypted" },
{ "pic", "image/x-pict" },
{ "pkg", "application/octet-stream" },
{ "pki", "application/pkixcmp" },
{ "pkipath", "application/pkix-pkipath" },
{ "pkpass", "application/vnd-com.apple.pkpass" },
{ "pko", "application/ynd.ms-pkipko" },
{ "plb", "application/vnd.3gpp.pic-bw-large" },
{ "plc", "application/vnd.mobius.plc" },
{ "plf", "application/vnd.pocketlearn" },
{ "pls", "application/pls+xml" },
{ "pl", "text/plain" },
{ "pma", "application/x-perfmon" },
{ "pmc", "application/x-perfmon" },
{ "pml", "application/x-perfmon" },
{ "pmr", "application/x-perfmon" },
{ "pmw", "application/x-perfmon" },
{ "png", "image/png" },
{ "pnm", "image/x-portable-anymap" },
{ "portpkg", "application/vnd.macports.portpkg" },
{ "pot,", "application/vnd.ms-powerpoint" },
{ "pot", "application/vnd.ms-powerpoint" },
{ "potm", "application/vnd.ms-powerpoint.template.macroenabled.12" },
{ "potx", "application/vnd.openxmlformats-officedocument.presentationml.template" },
{ "ppa", "application/vnd.ms-powerpoint" },
{ "ppam", "application/vnd.ms-powerpoint.addin.macroenabled.12" },
{ "ppd", "application/vnd.cups-ppd" },
{ "ppm", "image/x-portable-pixmap" },
{ "pps", "application/vnd.ms-powerpoint" },
{ "ppsm", "application/vnd.ms-powerpoint.slideshow.macroenabled.12" },
{ "ppsx", "application/vnd.openxmlformats-officedocument.presentationml.slideshow" },
{ "ppt", "application/vnd.ms-powerpoint" },
{ "pptm", "application/vnd.ms-powerpoint.presentation.macroenabled.12" },
{ "pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation" },
{ "pqa", "application/vnd.palm" },
{ "prc", "application/x-mobipocket-ebook" },
{ "pre", "application/vnd.lotus-freelance" },
{ "prf", "application/pics-rules" },
{ "ps", "application/postscript" },
{ "psb", "application/vnd.3gpp.pic-bw-small" },
{ "psd", "image/vnd.adobe.photoshop" },
{ "psf", "application/x-font-linux-psf" },
{ "p", "text/x-pascal" },
{ "ptid", "application/vnd.pvi.ptid1" },
{ "pub", "application/x-mspublisher" },
{ "pvb", "application/vnd.3gpp.pic-bw-var" },
{ "pwn", "application/vnd.3m.post-it-notes" },
{ "pwz", "application/vnd.ms-powerpoint" },
{ "pya", "audio/vnd.ms-playready.media.pya" },
{ "pyc", "application/x-python-code" },
{ "pyo", "application/x-python-code" },
{ "py", "text/x-python" },
{ "pyv", "video/vnd.ms-playready.media.pyv" },
{ "qam", "application/vnd.epson.quickanime" },
{ "qbo", "application/vnd.intu.qbo" },
{ "qfx", "application/vnd.intu.qfx" },
{ "qps", "application/vnd.publishare-delta-tree" },
{ "qt", "video/quicktime" },
{ "qwd", "application/vnd.quark.quarkxpress" },
{ "qwt", "application/vnd.quark.quarkxpress" },
{ "qxb", "application/vnd.quark.quarkxpress" },
{ "qxd", "application/vnd.quark.quarkxpress" },
{ "qxl", "application/vnd.quark.quarkxpress" },
{ "qxt", "application/vnd.quark.quarkxpress" },
{ "ra", "audio/x-pn-realaudio" },
{ "ram", "audio/x-pn-realaudio" },
{ "rar", "application/x-rar-compressed" },
{ "ras", "image/x-cmu-raster" },
{ "rcprofile", "application/vnd.ipunplugged.rcprofile" },
{ "rdf", "application/rdf+xml" },
{ "rdz", "application/vnd.data-vision.rdz" },
{ "rep", "application/vnd.businessobjects" },
{ "res", "application/x-dtbresource+xml" },
{ "rgb", "image/x-rgb" },
{ "rif", "application/reginfo+xml" },
{ "rl", "application/resource-lists+xml" },
{ "rlc", "image/vnd.fujixerox.edmics-rlc" },
{ "rld", "application/resource-lists-diff+xml" },
{ "rm", "application/vnd.rn-realmedia" },
{ "rmi", "audio/midi" },
{ "rmp", "audio/x-pn-realaudio-plugin" },
{ "rms", "application/vnd.jcp.javame.midlet-rms" },
{ "rnc", "application/relax-ng-compact-syntax" },
{ "roff", "text/troff" },
{ "rpm", "application/x-rpm" },
{ "rpss", "application/vnd.nokia.radio-presets" },
{ "rpst", "application/vnd.nokia.radio-preset" },
{ "rq", "application/sparql-query" },
{ "rs", "application/rls-services+xml" },
{ "rsd", "application/rsd+xml" },
{ "rss", "application/rss+xml" },
{ "rtf", "application/rtf" },
{ "rtx", "text/richtext" },
{ "saf", "application/vnd.yamaha.smaf-audio" },
{ "sbml", "application/sbml+xml" },
{ "sc", "application/vnd.ibm.secure-container" },
{ "scd", "application/x-msschedule" },
{ "scm", "application/vnd.lotus-screencam" },
{ "scq", "application/scvp-cv-request" },
{ "scs", "application/scvp-cv-response" },
{ "sct", "text/scriptlet" },
{ "scurl", "text/vnd.curl.scurl" },
{ "sda", "application/vnd.stardivision.draw" },
{ "sdc", "application/vnd.stardivision.calc" },
{ "sdd", "application/vnd.stardivision.impress" },
{ "sdkd", "application/vnd.solent.sdkm+xml" },
{ "sdkm", "application/vnd.solent.sdkm+xml" },
{ "sdp", "application/sdp" },
{ "sdw", "application/vnd.stardivision.writer" },
{ "see", "application/vnd.seemail" },
{ "seed", "application/vnd.fdsn.seed" },
{ "sema", "application/vnd.sema" },
{ "semd", "application/vnd.semd" },
{ "semf", "application/vnd.semf" },
{ "ser", "application/java-serialized-object" },
{ "setpay", "application/set-payment-initiation" },
{ "setreg", "application/set-registration-initiation" },
{ "sfd-hdstx", "application/vnd.hydrostatix.sof-data" },
{ "sfs", "application/vnd.spotfire.sfs" },
{ "sgl", "application/vnd.stardivision.writer-global" },
{ "sgml", "text/sgml" },
{ "sgm", "text/sgml" },
{ "sh", "application/x-sh" },
{ "shar", "application/x-shar" },
{ "shf", "application/shf+xml" },
{ "sic", "application/vnd.wap.sic" },
{ "sig", "application/pgp-signature" },
{ "silo", "model/mesh" },
{ "sis", "application/vnd.symbian.install" },
{ "sisx", "application/vnd.symbian.install" },
{ "sit", "application/x-stuffit" },
{ "si", "text/vnd.wap.si" },
{ "sitx", "application/x-stuffitx" },
{ "skd", "application/vnd.koan" },
{ "skm", "application/vnd.koan" },
{ "skp", "application/vnd.koan" },
{ "skt", "application/vnd.koan" },
{ "slc", "application/vnd.wap.slc" },
{ "sldm", "application/vnd.ms-powerpoint.slide.macroenabled.12" },
{ "sldx", "application/vnd.openxmlformats-officedocument.presentationml.slide" },
{ "slt", "application/vnd.epson.salt" },
{ "sl", "text/vnd.wap.sl" },
{ "smf", "application/vnd.stardivision.math" },
{ "smi", "application/smil+xml" },
{ "smil", "application/smil+xml" },
{ "snd", "audio/basic" },
{ "snf", "application/x-font-snf" },
{ "so", "application/octet-stream" },
{ "spc", "application/x-pkcs7-certificates" },
{ "spf", "application/vnd.yamaha.smaf-phrase" },
{ "spl", "application/x-futuresplash" },
{ "spot", "text/vnd.in3d.spot" },
{ "spp", "application/scvp-vp-response" },
{ "spq", "application/scvp-vp-request" },
{ "spx", "audio/ogg" },
{ "src", "application/x-wais-source" },
{ "srx", "application/sparql-results+xml" },
{ "sse", "application/vnd.kodak-descriptor" },
{ "ssf", "application/vnd.epson.ssf" },
{ "ssml", "application/ssml+xml" },
{ "sst", "application/vnd.ms-pkicertstore" },
{ "stc", "application/vnd.sun.xml.calc.template" },
{ "std", "application/vnd.sun.xml.draw.template" },
{ "s", "text/x-asm" },
{ "stf", "application/vnd.wt.stf" },
{ "sti", "application/vnd.sun.xml.impress.template" },
{ "stk", "application/hyperstudio" },
{ "stl", "application/vnd.ms-pki.stl" },
{ "stm", "text/html" },
{ "str", "application/vnd.pg.format" },
{ "stw", "application/vnd.sun.xml.writer.template" },
{ "sus", "application/vnd.sus-calendar" },
{ "susp", "application/vnd.sus-calendar" },
{ "sv4cpio", "application/x-sv4cpio" },
{ "sv4crc", "application/x-sv4crc" },
{ "svd", "application/vnd.svd" },
{ "svg", "image/svg+xml" },
{ "svgz", "image/svg+xml" },
{ "swa", "application/x-director" },
{ "swf", "application/x-shockwave-flash" },
{ "swi", "application/vnd.arastra.swi" },
{ "sxc", "application/vnd.sun.xml.calc" },
{ "sxd", "application/vnd.sun.xml.draw" },
{ "sxg", "application/vnd.sun.xml.writer.global" },
{ "sxi", "application/vnd.sun.xml.impress" },
{ "sxm", "application/vnd.sun.xml.math" },
{ "sxw", "application/vnd.sun.xml.writer" },
{ "tao", "application/vnd.tao.intent-module-archive" },
{ "t", "application/x-troff" },
{ "tar", "application/x-tar" },
{ "tcap", "application/vnd.3gpp2.tcap" },
{ "tcl", "application/x-tcl" },
{ "teacher", "application/vnd.smart.teacher" },
{ "tex", "application/x-tex" },
{ "texi", "application/x-texinfo" },
{ "texinfo", "application/x-texinfo" },
{ "text", "text/plain" },
{ "tfm", "application/x-tex-tfm" },
{ "tgz", "application/x-gzip" },
{ "tiff", "image/tiff" },
{ "tif", "image/tiff" },
{ "tmo", "application/vnd.tmobile-livetv" },
{ "torrent", "application/x-bittorrent" },
{ "tpl", "application/vnd.groove-tool-template" },
{ "tpt", "application/vnd.trid.tpt" },
{ "tra", "application/vnd.trueapp" },
{ "trm", "application/x-msterminal" },
{ "tr", "text/troff" },
{ "tsv", "text/tab-separated-values" },
{ "ttc", "application/x-font-ttf" },
{ "ttf", "application/x-font-ttf" },
{ "twd", "application/vnd.simtech-mindmapper" },
{ "twds", "application/vnd.simtech-mindmapper" },
{ "txd", "application/vnd.genomatix.tuxedo" },
{ "txf", "application/vnd.mobius.txf" },
{ "txt", "text/plain" },
{ "u32", "application/x-authorware-bin" },
{ "udeb", "application/x-debian-package" },
{ "ufd", "application/vnd.ufdl" },
{ "ufdl", "application/vnd.ufdl" },
{ "uls", "text/iuls" },
{ "umj", "application/vnd.umajin" },
{ "unityweb", "application/vnd.unity" },
{ "uoml", "application/vnd.uoml+xml" },
{ "uris", "text/uri-list" },
{ "uri", "text/uri-list" },
{ "urls", "text/uri-list" },
{ "ustar", "application/x-ustar" },
{ "utz", "application/vnd.uiq.theme" },
{ "uu", "text/x-uuencode" },
{ "vcd", "application/x-cdlink" },
{ "vcf", "text/x-vcard" },
{ "vcg", "application/vnd.groove-vcard" },
{ "vcs", "text/x-vcalendar" },
{ "vcx", "application/vnd.vcx" },
{ "vis", "application/vnd.visionary" },
{ "viv", "video/vnd.vivo" },
{ "vor", "application/vnd.stardivision.writer" },
{ "vox", "application/x-authorware-bin" },
{ "vrml", "x-world/x-vrml" },
{ "vsd", "application/vnd.visio" },
{ "vsf", "application/vnd.vsf" },
{ "vss", "application/vnd.visio" },
{ "vst", "application/vnd.visio" },
{ "vsw", "application/vnd.visio" },
{ "vtu", "model/vnd.vtu" },
{ "vxml", "application/voicexml+xml" },
{ "w3d", "application/x-director" },
{ "wad", "application/x-doom" },
{ "wav", "audio/x-wav" },
{ "wax", "audio/x-ms-wax" },
{ "wbmp", "image/vnd.wap.wbmp" },
{ "wbs", "application/vnd.criticaltools.wbs+xml" },
{ "wbxml", "application/vnd.wap.wbxml" },
{ "wcm", "application/vnd.ms-works" },
{ "wdb", "application/vnd.ms-works" },
{ "wiz", "application/msword" },
{ "wks", "application/vnd.ms-works" },
{ "wma", "audio/x-ms-wma" },
{ "wmd", "application/x-ms-wmd" },
{ "wmf", "application/x-msmetafile" },
{ "wmlc", "application/vnd.wap.wmlc" },
{ "wmlsc", "application/vnd.wap.wmlscriptc" },
{ "wmls", "text/vnd.wap.wmlscript" },
{ "wml", "text/vnd.wap.wml" },
{ "wm", "video/x-ms-wm" },
{ "wmv", "video/x-ms-wmv" },
{ "wmx", "video/x-ms-wmx" },
{ "wmz", "application/x-ms-wmz" },
{ "wpd", "application/vnd.wordperfect" },
{ "wpl", "application/vnd.ms-wpl" },
{ "wps", "application/vnd.ms-works" },
{ "wqd", "application/vnd.wqd" },
{ "wri", "application/x-mswrite" },
{ "wrl", "x-world/x-vrml" },
{ "wrz", "x-world/x-vrml" },
{ "wsdl", "application/wsdl+xml" },
{ "wspolicy", "application/wspolicy+xml" },
{ "wtb", "application/vnd.webturbo" },
{ "wvx", "video/x-ms-wvx" },
{ "x32", "application/x-authorware-bin" },
{ "x3d", "application/vnd.hzn-3d-crossword" },
{ "xaf", "x-world/x-vrml" },
{ "xap", "application/x-silverlight-app" },
{ "xar", "application/vnd.xara" },
{ "xbap", "application/x-ms-xbap" },
{ "xbd", "application/vnd.fujixerox.docuworks.binder" },
{ "xbm", "image/x-xbitmap" },
{ "xdm", "application/vnd.syncml.dm+xml" },
{ "xdp", "application/vnd.adobe.xdp+xml" },
{ "xdw", "application/vnd.fujixerox.docuworks" },
{ "xenc", "application/xenc+xml" },
{ "xer", "application/patch-ops-error+xml" },
{ "xfdf", "application/vnd.adobe.xfdf" },
{ "xfdl", "application/vnd.xfdl" },
{ "xht", "application/xhtml+xml" },
{ "xhtml", "application/xhtml+xml" },
{ "xhvml", "application/xv+xml" },
{ "xif", "image/vnd.xiff" },
{ "xla", "application/vnd.ms-excel" },
{ "xlam", "application/vnd.ms-excel.addin.macroenabled.12" },
{ "xlb", "application/vnd.ms-excel" },
{ "xlc", "application/vnd.ms-excel" },
{ "xlm", "application/vnd.ms-excel" },
{ "xls", "application/vnd.ms-excel" },
{ "xlsb", "application/vnd.ms-excel.sheet.binary.macroenabled.12" },
{ "xlsm", "application/vnd.ms-excel.sheet.macroenabled.12" },
{ "xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" },
{ "xlt", "application/vnd.ms-excel" },
{ "xltm", "application/vnd.ms-excel.template.macroenabled.12" },
{ "xltx", "application/vnd.openxmlformats-officedocument.spreadsheetml.template" },
{ "xlw", "application/vnd.ms-excel" },
{ "xml", "application/xml" },
{ "xo", "application/vnd.olpc-sugar" },
{ "xof", "x-world/x-vrml" },
{ "xop", "application/xop+xml" },
{ "xpdl", "application/xml" },
{ "xpi", "application/x-xpinstall" },
{ "xpm", "image/x-xpixmap" },
{ "xpr", "application/vnd.is-xpr" },
{ "xps", "application/vnd.ms-xpsdocument" },
{ "xpw", "application/vnd.intercon.formnet" },
{ "xpx", "application/vnd.intercon.formnet" },
{ "xsl", "application/xml" },
{ "xslt", "application/xslt+xml" },
{ "xsm", "application/vnd.syncml+xml" },
{ "xspf", "application/xspf+xml" },
{ "xul", "application/vnd.mozilla.xul+xml" },
{ "xvm", "application/xv+xml" },
{ "xvml", "application/xv+xml" },
{ "xwd", "image/x-xwindowdump" },
{ "xyz", "chemical/x-xyz" },
{ "z", "application/x-compress" },
{ "zaz", "application/vnd.zzazz.deck+xml" },
{ "zip", "application/zip" },
{ "zir", "application/vnd.zul" },
{ "zirz", "application/vnd.zul" },
{ "zmm", "application/vnd.handheld-entertainment+xml" }
};
public static boolean isDefaultMimeType(String mimeType) {
return isSameMimeType(mimeType, DEFAULT_ATTACHMENT_MIME_TYPE);
}
public static String getMimeTypeByExtension(String filename) {
String returnedType = null;
String extension = null;
if (filename != null && filename.lastIndexOf('.') != -1) {
extension = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(Locale.US);
returnedType = android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
}
// If the MIME type set by the user's mailer is application/octet-stream, try to figure
// out whether there's a sane file type extension.
if (returnedType != null && !isSameMimeType(returnedType, DEFAULT_ATTACHMENT_MIME_TYPE)) {
return returnedType;
} else if (extension != null) {
for (String[] contentTypeMapEntry : MIME_TYPE_BY_EXTENSION_MAP) {
if (contentTypeMapEntry[0].equals(extension)) {
return contentTypeMapEntry[1];
}
}
}
return DEFAULT_ATTACHMENT_MIME_TYPE;
}
public static String getExtensionByMimeType(@NotNull String mimeType) {
String lowerCaseMimeType = mimeType.toLowerCase(Locale.US);
for (String[] contentTypeMapEntry : MIME_TYPE_BY_EXTENSION_MAP) {
if (contentTypeMapEntry[1].equals(lowerCaseMimeType)) {
return contentTypeMapEntry[0];
}
}
return null;
}
public static boolean isSupportedImageType(String mimeType) {
return isSameMimeType(mimeType, "image/jpeg") || isSameMimeType(mimeType, "image/png") ||
isSameMimeType(mimeType, "image/gif") || isSameMimeType(mimeType, "image/webp");
}
public static boolean isSupportedImageExtension(String filename) {
String mimeType = getMimeTypeByExtension(filename);
return isSupportedImageType(mimeType);
}
public static boolean isSameMimeType(String mimeType, String otherMimeType) {
return mimeType != null && mimeType.equalsIgnoreCase(otherMimeType);
}
}

View file

@ -0,0 +1,3 @@
package com.fsck.k9.helper
class MutableBoolean(var value: Boolean)

View file

@ -0,0 +1,9 @@
package com.fsck.k9.helper
import java.util.concurrent.ThreadFactory
class NamedThreadFactory(private val threadNamePrefix: String) : ThreadFactory {
var counter: Int = 0
override fun newThread(runnable: Runnable) = Thread(runnable, "$threadNamePrefix-${ counter++ }")
}

View file

@ -0,0 +1,31 @@
package com.fsck.k9.helper;
import android.os.Parcel;
import android.os.Parcelable;
// Source: http://stackoverflow.com/a/18000094
public class ParcelableUtil {
public static byte[] marshall(Parcelable parceable) {
Parcel parcel = Parcel.obtain();
parceable.writeToParcel(parcel, 0);
byte[] bytes = parcel.marshall();
parcel.recycle();
return bytes;
}
public static <T> T unmarshall(byte[] bytes, Parcelable.Creator<T> creator) {
Parcel parcel = unmarshall(bytes);
T result = creator.createFromParcel(parcel);
parcel.recycle();
return result;
}
private static Parcel unmarshall(byte[] bytes) {
Parcel parcel = Parcel.obtain();
parcel.unmarshall(bytes, 0, bytes.length);
parcel.setDataPosition(0);
return parcel;
}
}

View file

@ -0,0 +1,12 @@
package com.fsck.k9.helper
import android.app.PendingIntent
import android.os.Build
object PendingIntentCompat {
@JvmField
val FLAG_IMMUTABLE = if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0
@JvmField
val FLAG_MUTABLE = if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE else 0
}

View file

@ -0,0 +1,90 @@
package com.fsck.k9.helper;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import androidx.annotation.VisibleForTesting;
import com.fsck.k9.Account;
import com.fsck.k9.mail.Address;
import com.fsck.k9.mail.Message;
import com.fsck.k9.mail.Message.RecipientType;
public class ReplyToParser {
public ReplyToAddresses getRecipientsToReplyTo(Message message, Account account) {
Address[] candidateAddress;
Address[] replyToAddresses = message.getReplyTo();
Address[] listPostAddresses = ListHeaders.getListPostAddresses(message);
Address[] fromAddresses = message.getFrom();
if (replyToAddresses.length > 0) {
candidateAddress = replyToAddresses;
} else if (listPostAddresses.length > 0) {
candidateAddress = listPostAddresses;
} else {
candidateAddress = fromAddresses;
}
boolean replyToAddressIsUserIdentity = account.isAnIdentity(candidateAddress);
if (replyToAddressIsUserIdentity) {
candidateAddress = message.getRecipients(RecipientType.TO);
}
return new ReplyToAddresses(candidateAddress);
}
public ReplyToAddresses getRecipientsToReplyAllTo(Message message, Account account) {
List<Address> replyToAddresses = Arrays.asList(getRecipientsToReplyTo(message, account).to);
HashSet<Address> alreadyAddedAddresses = new HashSet<>(replyToAddresses);
ArrayList<Address> toAddresses = new ArrayList<>(replyToAddresses);
ArrayList<Address> ccAddresses = new ArrayList<>();
for (Address address : message.getFrom()) {
if (!alreadyAddedAddresses.contains(address) && !account.isAnIdentity(address)) {
toAddresses.add(address);
alreadyAddedAddresses.add(address);
}
}
for (Address address : message.getRecipients(RecipientType.TO)) {
if (!alreadyAddedAddresses.contains(address) && !account.isAnIdentity(address)) {
toAddresses.add(address);
alreadyAddedAddresses.add(address);
}
}
for (Address address : message.getRecipients(RecipientType.CC)) {
if (!alreadyAddedAddresses.contains(address) && !account.isAnIdentity(address)) {
ccAddresses.add(address);
alreadyAddedAddresses.add(address);
}
}
return new ReplyToAddresses(toAddresses, ccAddresses);
}
public static class ReplyToAddresses {
public final Address[] to;
public final Address[] cc;
@VisibleForTesting
public ReplyToAddresses(List<Address> toAddresses, List<Address> ccAddresses) {
to = toAddresses.toArray(new Address[toAddresses.size()]);
cc = ccAddresses.toArray(new Address[ccAddresses.size()]);
}
@VisibleForTesting
public ReplyToAddresses(Address[] toAddresses) {
to = toAddresses;
cc = new Address[0];
}
}
}

View file

@ -0,0 +1,63 @@
package com.fsck.k9.helper;
import android.os.Bundle;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
public class RetainFragment<T> extends Fragment {
private T data;
private boolean cleared;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setRetainInstance(true);
}
public T getData() {
return data;
}
public boolean hasData() {
return data != null;
}
public void setData(T data) {
this.data = data;
}
public static <T> RetainFragment<T> findOrNull(FragmentManager fm, String tag) {
// noinspection unchecked, we know this is the the right type
return (RetainFragment<T>) fm.findFragmentByTag(tag);
}
public static <T> RetainFragment<T> findOrCreate(FragmentManager fm, String tag) {
// noinspection unchecked, we know this is the the right type
RetainFragment<T> retainFragment = (RetainFragment<T>) fm.findFragmentByTag(tag);
if (retainFragment == null || retainFragment.cleared) {
retainFragment = new RetainFragment<>();
fm.beginTransaction()
.add(retainFragment, tag)
.commitAllowingStateLoss();
}
return retainFragment;
}
public void clearAndRemove(FragmentManager fm) {
data = null;
cleared = true;
if (fm.isDestroyed()) {
return;
}
fm.beginTransaction()
.remove(this)
.commitAllowingStateLoss();
}
}

View file

@ -0,0 +1,22 @@
package com.fsck.k9.helper;
import android.text.Editable;
import android.text.TextWatcher;
/**
* all methods empty - but this way we can have TextWatchers with less boiler-plate where
* we just override the methods we want and not always all 3
*/
public class SimpleTextWatcher implements TextWatcher {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
@Override
public void afterTextChanged(Editable s) {
}
}

View file

@ -0,0 +1,76 @@
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.fsck.k9.helper;
import java.util.concurrent.atomic.AtomicBoolean;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import timber.log.Timber;
/**
* A lifecycle-aware observable that sends only new updates after subscription, used for events like
* navigation and Snackbar messages.
* <p>
* This avoids a common problem with events: on configuration change (like rotation) an update
* can be emitted if the observer is active. This LiveData only calls the observable if there's an
* explicit call to setValue() or call().
* <p>
* Note that only one observer is going to be notified of changes.
*/
public class SingleLiveEvent<T> extends MutableLiveData<T> {
private final AtomicBoolean pending = new AtomicBoolean(false);
@MainThread
public void observe(@NonNull LifecycleOwner owner, @NonNull final Observer<? super T> observer) {
if (hasActiveObservers()) {
Timber.w("Multiple observers registered but only one will be notified of changes.");
}
// Observe the internal MutableLiveData
super.observe(owner, new Observer<T>() {
@Override
public void onChanged(@Nullable T t) {
if (pending.compareAndSet(true, false)) {
observer.onChanged(t);
}
}
});
}
@MainThread
public void setValue(@Nullable T t) {
pending.set(true);
super.setValue(t);
}
/**
* Used for cases where T is Void, to make calls cleaner.
*/
@MainThread
public void recall() {
setValue(getValue());
}
}

View file

@ -0,0 +1,5 @@
@file:JvmName("StringHelper")
package com.fsck.k9.helper
fun isNullOrEmpty(text: String?) = text.isNullOrEmpty()

View file

@ -0,0 +1,22 @@
package com.fsck.k9.helper
import android.os.SystemClock
/**
* Executes the given [block] and returns elapsed realtime in milliseconds.
*/
inline fun measureRealtimeMillis(block: () -> Unit): Long {
val start = SystemClock.elapsedRealtime()
block()
return SystemClock.elapsedRealtime() - start
}
/**
* Executes the given [block] and returns pair of elapsed realtime in milliseconds and result of the code block.
*/
inline fun <T> measureRealtimeMillisWithResult(block: () -> T): Pair<Long, T> {
val start = SystemClock.elapsedRealtime()
val result = block()
val elapsedTime = SystemClock.elapsedRealtime() - start
return elapsedTime to result
}

View file

@ -0,0 +1,10 @@
package com.fsck.k9.helper
import android.net.Uri
sealed interface UnsubscribeUri {
val uri: Uri
}
data class MailtoUnsubscribeUri(override val uri: Uri) : UnsubscribeUri
data class HttpsUnsubscribeUri(override val uri: Uri) : UnsubscribeUri

Some files were not shown because too many files have changed in this diff Show more