Repo created
This commit is contained in:
parent
75dc487a7a
commit
39c29d175b
6317 changed files with 388324 additions and 2 deletions
74
legacy/core/build.gradle.kts
Normal file
74
legacy/core/build.gradle.kts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.android)
|
||||
alias(libs.plugins.kotlin.parcelize)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.mail.common)
|
||||
api(projects.backend.api)
|
||||
api(projects.library.htmlCleaner)
|
||||
api(projects.core.mail.mailserver)
|
||||
api(projects.core.android.common)
|
||||
api(projects.core.android.account)
|
||||
api(projects.core.preference.impl)
|
||||
api(projects.core.android.logging)
|
||||
api(projects.core.logging.implFile)
|
||||
api(projects.core.logging.implComposite)
|
||||
api(projects.core.android.network)
|
||||
api(projects.core.outcome)
|
||||
api(projects.feature.mail.folder.api)
|
||||
api(projects.feature.account.storage.legacy)
|
||||
|
||||
api(projects.feature.search.implLegacy)
|
||||
api(projects.feature.mail.account.api)
|
||||
api(projects.legacy.di)
|
||||
api(projects.legacy.mailstore)
|
||||
api(projects.legacy.message)
|
||||
implementation(projects.feature.notification.api)
|
||||
|
||||
implementation(projects.plugins.openpgpApiLib.openpgpApi)
|
||||
implementation(projects.feature.telemetry.api)
|
||||
implementation(projects.core.featureflag)
|
||||
implementation(projects.core.logging.implComposite)
|
||||
|
||||
api(libs.androidx.annotation)
|
||||
|
||||
implementation(libs.okio)
|
||||
implementation(libs.commons.io)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.work.runtime)
|
||||
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)
|
||||
implementation(projects.feature.navigation.drawer.api)
|
||||
|
||||
testApi(projects.core.testing)
|
||||
testApi(projects.core.android.testing)
|
||||
testImplementation(projects.core.logging.testing)
|
||||
testImplementation(projects.feature.telemetry.noop)
|
||||
testImplementation(projects.mail.testing)
|
||||
testImplementation(projects.backend.imap)
|
||||
testImplementation(projects.mail.protocols.smtp)
|
||||
testImplementation(projects.legacy.storage)
|
||||
|
||||
testImplementation(libs.kotlin.test)
|
||||
testImplementation(libs.kotlin.reflect)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.androidx.test.core)
|
||||
testImplementation(libs.jdom2)
|
||||
|
||||
// test fakes
|
||||
testImplementation(projects.feature.account.fake)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.fsck.k9.core"
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
7
legacy/core/src/main/AndroidManifest.xml
Normal file
7
legacy/core/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
</manifest>
|
||||
9
legacy/core/src/main/java/com/fsck/k9/AppConfig.kt
Normal file
9
legacy/core/src/main/java/com/fsck/k9/AppConfig.kt
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package com.fsck.k9
|
||||
|
||||
interface AppConfig {
|
||||
val componentsToDisable: List<Class<*>>
|
||||
}
|
||||
|
||||
class DefaultAppConfig(
|
||||
override val componentsToDisable: List<Class<*>>,
|
||||
) : AppConfig
|
||||
93
legacy/core/src/main/java/com/fsck/k9/Core.kt
Normal file
93
legacy/core/src/main/java/com/fsck/k9/Core.kt
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import com.fsck.k9.core.BuildConfig
|
||||
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.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.koin.core.qualifier.named
|
||||
|
||||
object Core : KoinComponent {
|
||||
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 should be called from [Application.attachBaseContext()][android.app.Application.attachBaseContext] before
|
||||
* calling through to the super class's `attachBaseContext()` implementation and before initializing the dependency
|
||||
* injection library.
|
||||
*/
|
||||
fun earlyInit() {
|
||||
if (BuildConfig.DEBUG) {
|
||||
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().getAccounts().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.getAccounts()
|
||||
notificationController.restoreNewMailNotifications(accounts)
|
||||
}
|
||||
}
|
||||
}
|
||||
38
legacy/core/src/main/java/com/fsck/k9/CoreKoinModules.kt
Normal file
38
legacy/core/src/main/java/com/fsck/k9/CoreKoinModules.kt
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
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.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.notification.coreNotificationModule
|
||||
import com.fsck.k9.power.powerModule
|
||||
import com.fsck.k9.preferences.preferencesModule
|
||||
import net.thunderbird.core.android.logging.loggingModule
|
||||
import net.thunderbird.core.android.network.coreAndroidNetworkModule
|
||||
import net.thunderbird.feature.account.storage.legacy.featureAccountStorageLegacyModule
|
||||
|
||||
val legacyCoreModules = listOf(
|
||||
mainModule,
|
||||
coreAndroidNetworkModule,
|
||||
openPgpModule,
|
||||
autocryptModule,
|
||||
mailStoreModule,
|
||||
extractorModule,
|
||||
htmlModule,
|
||||
quoteModule,
|
||||
coreNotificationModule,
|
||||
controllerModule,
|
||||
controllerPushModule,
|
||||
jobModule,
|
||||
helperModule,
|
||||
preferencesModule,
|
||||
powerModule,
|
||||
loggingModule,
|
||||
featureAccountStorageLegacyModule,
|
||||
)
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import com.fsck.k9.notification.PushNotificationState
|
||||
|
||||
interface CoreResourceProvider {
|
||||
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 replyHeader(sender: String): String
|
||||
fun replyHeader(sender: String, sentDate: String): String
|
||||
|
||||
fun searchUnifiedInboxTitle(): String
|
||||
fun searchUnifiedInboxDetail(): String
|
||||
|
||||
val iconPushNotification: Int
|
||||
fun pushNotificationText(notificationState: PushNotificationState): String
|
||||
fun pushNotificationInfoText(): String
|
||||
fun pushNotificationGrantAlarmPermissionText(): String
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
109
legacy/core/src/main/java/com/fsck/k9/FontSizes.kt
Normal file
109
legacy/core/src/main/java/com/fsck/k9/FontSizes.kt
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import android.util.TypedValue
|
||||
import android.widget.TextView
|
||||
import net.thunderbird.core.preference.storage.Storage
|
||||
import net.thunderbird.core.preference.storage.StorageEditor
|
||||
|
||||
/**
|
||||
* Manage font size of the information displayed in the message list and in the message view.
|
||||
*/
|
||||
class FontSizes {
|
||||
var messageListSubject: Int
|
||||
var messageListSender: Int
|
||||
var messageListDate: Int
|
||||
var messageListPreview: Int
|
||||
var messageViewAccountName: Int
|
||||
var messageViewSender: Int
|
||||
var messageViewRecipients: Int
|
||||
var messageViewSubject: Int
|
||||
var messageViewDate: Int
|
||||
var messageViewContentAsPercent: Int
|
||||
var messageComposeInput: Int
|
||||
|
||||
init {
|
||||
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
|
||||
messageComposeInput = MEDIUM
|
||||
messageViewContentAsPercent = DEFAULT_CONTENT_SIZE_IN_PERCENT
|
||||
}
|
||||
|
||||
fun save(editor: StorageEditor) {
|
||||
with(editor) {
|
||||
putInt(MESSAGE_LIST_SUBJECT, messageListSubject)
|
||||
putInt(MESSAGE_LIST_SENDER, messageListSender)
|
||||
putInt(MESSAGE_LIST_DATE, messageListDate)
|
||||
putInt(MESSAGE_LIST_PREVIEW, messageListPreview)
|
||||
|
||||
putInt(MESSAGE_VIEW_ACCOUNT_NAME, messageViewAccountName)
|
||||
putInt(MESSAGE_VIEW_SENDER, messageViewSender)
|
||||
putInt(MESSAGE_VIEW_RECIPIENTS, messageViewRecipients)
|
||||
putInt(MESSAGE_VIEW_SUBJECT, messageViewSubject)
|
||||
putInt(MESSAGE_VIEW_DATE, messageViewDate)
|
||||
putInt(MESSAGE_VIEW_CONTENT_PERCENT, messageViewContentAsPercent)
|
||||
|
||||
putInt(MESSAGE_COMPOSE_INPUT, messageComposeInput)
|
||||
}
|
||||
}
|
||||
|
||||
fun 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 fun loadMessageViewContentPercent(storage: Storage) {
|
||||
messageViewContentAsPercent = storage.getInt(MESSAGE_VIEW_CONTENT_PERCENT, DEFAULT_CONTENT_SIZE_IN_PERCENT)
|
||||
}
|
||||
|
||||
// This, arguably, should live somewhere in a view class, but since we call it from activities, fragments
|
||||
// and views, where isn't exactly clear.
|
||||
fun setViewTextSize(view: TextView, fontSize: Int) {
|
||||
if (fontSize != FONT_DEFAULT) {
|
||||
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSize.toFloat())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MESSAGE_LIST_SUBJECT = "fontSizeMessageListSubject"
|
||||
private const val MESSAGE_LIST_SENDER = "fontSizeMessageListSender"
|
||||
private const val MESSAGE_LIST_DATE = "fontSizeMessageListDate"
|
||||
private const val MESSAGE_LIST_PREVIEW = "fontSizeMessageListPreview"
|
||||
private const val MESSAGE_VIEW_ACCOUNT_NAME = "fontSizeMessageViewAccountName"
|
||||
private const val MESSAGE_VIEW_SENDER = "fontSizeMessageViewSender"
|
||||
private const val MESSAGE_VIEW_RECIPIENTS = "fontSizeMessageViewTo"
|
||||
private const val MESSAGE_VIEW_SUBJECT = "fontSizeMessageViewSubject"
|
||||
private const val MESSAGE_VIEW_DATE = "fontSizeMessageViewDate"
|
||||
private const val MESSAGE_VIEW_CONTENT_PERCENT = "fontSizeMessageViewContentPercent"
|
||||
private const val MESSAGE_COMPOSE_INPUT = "fontSizeMessageComposeInput"
|
||||
|
||||
private const val DEFAULT_CONTENT_SIZE_IN_PERCENT = 100
|
||||
|
||||
const val FONT_DEFAULT = -1 // Don't force-reset the size of this setting
|
||||
const val FONT_10SP = 10
|
||||
const val FONT_12SP = 12
|
||||
const val SMALL = 14 // ?android:attr/textAppearanceSmall
|
||||
const val FONT_16SP = 16
|
||||
const val MEDIUM = 18 // ?android:attr/textAppearanceMedium
|
||||
const val FONT_20SP = 20
|
||||
const val LARGE = 22 // ?android:attr/textAppearanceLarge
|
||||
}
|
||||
}
|
||||
428
legacy/core/src/main/java/com/fsck/k9/K9.kt
Normal file
428
legacy/core/src/main/java/com/fsck/k9/K9.kt
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import app.k9mail.feature.telemetry.api.TelemetryManager
|
||||
import com.fsck.k9.K9.DATABASE_VERSION_CACHE
|
||||
import com.fsck.k9.K9.areDatabasesUpToDate
|
||||
import com.fsck.k9.K9.checkCachedDatabaseVersion
|
||||
import com.fsck.k9.K9.setDatabasesUpToDate
|
||||
import com.fsck.k9.mail.K9MailLib
|
||||
import com.fsck.k9.mailstore.LocalStore
|
||||
import com.fsck.k9.preferences.DefaultGeneralSettingsManager
|
||||
import net.thunderbird.core.android.account.AccountDefaultsProvider
|
||||
import net.thunderbird.core.android.account.SortType
|
||||
import net.thunderbird.core.common.action.SwipeAction
|
||||
import net.thunderbird.core.common.action.SwipeActions
|
||||
import net.thunderbird.core.featureflag.FeatureFlagProvider
|
||||
import net.thunderbird.core.featureflag.toFeatureFlagKey
|
||||
import net.thunderbird.core.preference.storage.Storage
|
||||
import net.thunderbird.core.preference.storage.StorageEditor
|
||||
import net.thunderbird.core.preference.storage.getEnumOrDefault
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import timber.log.Timber
|
||||
|
||||
// TODO "Use GeneralSettingsManager and GeneralSettings instead"
|
||||
object K9 : KoinComponent {
|
||||
private val generalSettingsManager: DefaultGeneralSettingsManager by inject()
|
||||
private val telemetryManager: TelemetryManager by inject()
|
||||
private val featureFlagProvider: FeatureFlagProvider by inject()
|
||||
|
||||
/**
|
||||
* 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 isSensitiveDebugLoggingEnabled: Boolean = false
|
||||
|
||||
@JvmStatic
|
||||
val fontSizes = FontSizes()
|
||||
|
||||
@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 messageListPreviewLines = 2
|
||||
|
||||
@JvmStatic
|
||||
var contactNameColor = 0xFF1093F5.toInt()
|
||||
|
||||
var messageViewPostRemoveNavigation: PostRemoveNavigation = PostRemoveNavigation.ReturnToMessageList
|
||||
|
||||
var messageViewPostMarkAsUnreadNavigation: PostMarkAsUnreadNavigation =
|
||||
PostMarkAsUnreadNavigation.ReturnToMessageList
|
||||
|
||||
@JvmStatic
|
||||
var isUseVolumeKeysForNavigation = false
|
||||
|
||||
@JvmStatic
|
||||
var isShowAccountSelector = true
|
||||
|
||||
var isNotificationDuringQuietTimeEnabled = true
|
||||
|
||||
@get:Synchronized
|
||||
@set:Synchronized
|
||||
@JvmStatic
|
||||
var sortType: SortType = AccountDefaultsProvider.DEFAULT_SORT_TYPE
|
||||
private val sortAscending = mutableMapOf<SortType, Boolean>()
|
||||
|
||||
@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
|
||||
|
||||
// TODO: This is a feature-specific setting that doesn't need to be available to apps that don't include the
|
||||
// feature. Extract `Storage` and `StorageEditor` to a separate module so feature modules can retrieve and store
|
||||
// their own settings.
|
||||
var isTelemetryEnabled = false
|
||||
|
||||
// TODO: These are feature-specific settings that don't need to be available to apps that don't include the
|
||||
// feature.
|
||||
var fundingReminderReferenceTimestamp: Long = 0
|
||||
var fundingReminderShownTimestamp: Long = 0
|
||||
var fundingActivityCounterInMillis: Long = 0
|
||||
|
||||
@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 = generalSettingsManager.getConfig().debugging.isDebugLoggingEnabled
|
||||
|
||||
override fun debugSensitive(): Boolean = isSensitiveDebugLoggingEnabled
|
||||
},
|
||||
)
|
||||
|
||||
checkCachedDatabaseVersion(context)
|
||||
|
||||
loadPrefs(generalSettingsManager.storage)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Suppress("LongMethod")
|
||||
fun loadPrefs(storage: Storage) {
|
||||
isSensitiveDebugLoggingEnabled = storage.getBoolean("enableSensitiveLogging", false)
|
||||
isUseVolumeKeysForNavigation = storage.getBoolean("useVolumeKeysForNavigation", false)
|
||||
isShowAccountSelector = storage.getBoolean("showAccountSelector", true)
|
||||
messageListPreviewLines = storage.getInt("messageListPreviewLines", 2)
|
||||
|
||||
isNotificationDuringQuietTimeEnabled = storage.getBoolean("notificationDuringQuietTimeEnabled", true)
|
||||
|
||||
messageListDensity = storage.getEnum("messageListDensity", UiDensity.Default)
|
||||
contactNameColor = storage.getInt("registeredNameColor", 0xFF1093F5.toInt())
|
||||
messageViewPostRemoveNavigation =
|
||||
storage.getEnum("messageViewPostDeleteAction", PostRemoveNavigation.ReturnToMessageList)
|
||||
messageViewPostMarkAsUnreadNavigation =
|
||||
storage.getEnum("messageViewPostMarkAsUnreadAction", PostMarkAsUnreadNavigation.ReturnToMessageList)
|
||||
|
||||
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", AccountDefaultsProvider.DEFAULT_SORT_TYPE)
|
||||
|
||||
val sortAscendingSetting = storage.getBoolean("sortAscending", AccountDefaultsProvider.DEFAULT_SORT_ASCENDING)
|
||||
sortAscending[sortType] = sortAscendingSetting
|
||||
|
||||
notificationQuickDeleteBehaviour = storage.getEnum("notificationQuickDelete", NotificationQuickDelete.ALWAYS)
|
||||
|
||||
lockScreenNotificationVisibility = storage.getEnum(
|
||||
"lockScreenNotificationVisibility",
|
||||
LockScreenNotificationVisibility.MESSAGE_COUNT,
|
||||
)
|
||||
|
||||
featureFlagProvider.provide("disable_font_size_config".toFeatureFlagKey())
|
||||
.onDisabledOrUnavailable {
|
||||
fontSizes.load(storage)
|
||||
}
|
||||
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)
|
||||
|
||||
swipeRightAction = storage.getEnum(
|
||||
key = SwipeActions.KEY_SWIPE_ACTION_RIGHT,
|
||||
defaultValue = SwipeAction.ToggleSelection,
|
||||
)
|
||||
swipeLeftAction = storage.getEnum(
|
||||
key = SwipeActions.KEY_SWIPE_ACTION_LEFT,
|
||||
defaultValue = SwipeAction.ToggleRead,
|
||||
)
|
||||
|
||||
if (telemetryManager.isTelemetryFeatureIncluded()) {
|
||||
isTelemetryEnabled = storage.getBoolean("enableTelemetry", true)
|
||||
}
|
||||
|
||||
fundingReminderReferenceTimestamp = storage.getLong("fundingReminderReferenceTimestamp", 0)
|
||||
fundingReminderShownTimestamp = storage.getLong("fundingReminderShownTimestamp", 0)
|
||||
fundingActivityCounterInMillis = storage.getLong("fundingActivityCounterInMillis", 0)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
internal fun save(editor: StorageEditor) {
|
||||
editor.putBoolean("enableSensitiveLogging", isSensitiveDebugLoggingEnabled)
|
||||
editor.putBoolean("useVolumeKeysForNavigation", isUseVolumeKeysForNavigation)
|
||||
editor.putBoolean("notificationDuringQuietTimeEnabled", isNotificationDuringQuietTimeEnabled)
|
||||
editor.putEnum("messageListDensity", messageListDensity)
|
||||
editor.putBoolean("showAccountSelector", isShowAccountSelector)
|
||||
editor.putInt("messageListPreviewLines", messageListPreviewLines)
|
||||
editor.putInt("registeredNameColor", contactNameColor)
|
||||
editor.putEnum("messageViewPostDeleteAction", messageViewPostRemoveNavigation)
|
||||
editor.putEnum("messageViewPostMarkAsUnreadAction", messageViewPostMarkAsUnreadNavigation)
|
||||
|
||||
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("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(key = SwipeActions.KEY_SWIPE_ACTION_RIGHT, value = swipeRightAction)
|
||||
editor.putEnum(key = SwipeActions.KEY_SWIPE_ACTION_LEFT, value = swipeLeftAction)
|
||||
|
||||
if (telemetryManager.isTelemetryFeatureIncluded()) {
|
||||
editor.putBoolean("enableTelemetry", isTelemetryEnabled)
|
||||
}
|
||||
|
||||
editor.putLong("fundingReminderReferenceTimestamp", fundingReminderReferenceTimestamp)
|
||||
editor.putLong("fundingReminderShownTimestamp", fundingReminderShownTimestamp)
|
||||
editor.putLong("fundingActivityCounterInMillis", fundingActivityCounterInMillis)
|
||||
|
||||
fontSizes.save(editor)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun saveSettingsAsync() {
|
||||
generalSettingsManager.saveSettingsAsync()
|
||||
}
|
||||
|
||||
private inline fun <reified T : Enum<T>> Storage.getEnum(key: String, defaultValue: T): T {
|
||||
return try {
|
||||
getEnumOrDefault(key, defaultValue)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(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
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
|
||||
/**
|
||||
* The navigation actions that can be to performed after the user has deleted or moved a message from the message
|
||||
* view screen.
|
||||
*/
|
||||
enum class PostRemoveNavigation {
|
||||
ReturnToMessageList,
|
||||
ShowPreviousMessage,
|
||||
ShowNextMessage,
|
||||
}
|
||||
|
||||
/**
|
||||
* The navigation actions that can be to performed after the user has marked a message as unread from the message
|
||||
* view screen.
|
||||
*/
|
||||
enum class PostMarkAsUnreadNavigation {
|
||||
StayOnCurrentMessage,
|
||||
ReturnToMessageList,
|
||||
}
|
||||
}
|
||||
36
legacy/core/src/main/java/com/fsck/k9/KoinModule.kt
Normal file
36
legacy/core/src/main/java/com/fsck/k9/KoinModule.kt
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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 kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
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(),
|
||||
legacyAccountStorageHandler = get(),
|
||||
accountDefaultsProvider = 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()) }
|
||||
factory { EmailAddressValidator() }
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import com.fsck.k9.mail.ssl.LocalKeyStore
|
||||
import java.security.cert.CertificateException
|
||||
import java.security.cert.X509Certificate
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
import net.thunderbird.core.mail.mailserver.MailServerDirection
|
||||
|
||||
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: LegacyAccount, 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: LegacyAccount, 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: LegacyAccount) {
|
||||
account.incomingServerSettings.let { serverSettings ->
|
||||
localKeyStore.deleteCertificate(serverSettings.host!!, serverSettings.port)
|
||||
}
|
||||
|
||||
account.outgoingServerSettings.let { serverSettings ->
|
||||
localKeyStore.deleteCertificate(serverSettings.host!!, serverSettings.port)
|
||||
}
|
||||
}
|
||||
}
|
||||
333
legacy/core/src/main/java/com/fsck/k9/Preferences.kt
Normal file
333
legacy/core/src/main/java/com/fsck/k9/Preferences.kt
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import androidx.annotation.GuardedBy
|
||||
import androidx.annotation.RestrictTo
|
||||
import app.k9mail.legacy.di.DI
|
||||
import com.fsck.k9.mailstore.LocalStoreProvider
|
||||
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 net.thunderbird.core.android.account.AccountDefaultsProvider
|
||||
import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.UNASSIGNED_ACCOUNT_NUMBER
|
||||
import net.thunderbird.core.android.account.AccountManager
|
||||
import net.thunderbird.core.android.account.AccountRemovedListener
|
||||
import net.thunderbird.core.android.account.AccountsChangeListener
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import net.thunderbird.core.preference.storage.Storage
|
||||
import net.thunderbird.core.preference.storage.StorageEditor
|
||||
import net.thunderbird.core.preference.storage.StoragePersister
|
||||
import net.thunderbird.feature.account.storage.legacy.AccountDtoStorageHandler
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
class Preferences internal constructor(
|
||||
private val storagePersister: StoragePersister,
|
||||
private val localStoreProvider: LocalStoreProvider,
|
||||
private val legacyAccountStorageHandler: AccountDtoStorageHandler,
|
||||
private val backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
private val accountDefaultsProvider: AccountDefaultsProvider,
|
||||
) : AccountManager {
|
||||
private val accountLock = Any()
|
||||
private val storageLock = Any()
|
||||
|
||||
@GuardedBy("accountLock")
|
||||
private var accountsMap: MutableMap<String, LegacyAccount>? = null
|
||||
|
||||
@GuardedBy("accountLock")
|
||||
private var accountsInOrder = mutableListOf<LegacyAccount>()
|
||||
|
||||
@GuardedBy("accountLock")
|
||||
private var newAccount: LegacyAccount? = 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, LegacyAccount>()
|
||||
val accountsInOrder = mutableListOf<LegacyAccount>()
|
||||
|
||||
val accountUuids = storage.getStringOrNull("accountUuids")
|
||||
if (!accountUuids.isNullOrEmpty()) {
|
||||
accountUuids.split(",").forEach { uuid ->
|
||||
val existingAccount = accountsMap?.get(uuid)
|
||||
val account = existingAccount ?: LegacyAccount(
|
||||
uuid,
|
||||
K9::isSensitiveDebugLoggingEnabled,
|
||||
)
|
||||
legacyAccountStorageHandler.load(account, storage)
|
||||
|
||||
accounts[uuid] = account
|
||||
accountsInOrder.add(account)
|
||||
accountDefaultsProvider.applyOverwrites(account, storage)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
override fun getAccounts(): List<LegacyAccount> {
|
||||
synchronized(accountLock) {
|
||||
if (accountsMap == null) {
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
return accountsInOrder.toList()
|
||||
}
|
||||
}
|
||||
|
||||
private val completeAccounts: List<LegacyAccount>
|
||||
get() = getAccounts().filter { it.isFinishedSetup }
|
||||
|
||||
override fun getAccount(accountUuid: String): LegacyAccount? {
|
||||
synchronized(accountLock) {
|
||||
if (accountsMap == null) {
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
return accountsMap!![accountUuid]
|
||||
}
|
||||
}
|
||||
|
||||
override fun getAccountFlow(accountUuid: String): Flow<LegacyAccount> {
|
||||
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<LegacyAccount>> {
|
||||
return callbackFlow {
|
||||
send(completeAccounts)
|
||||
|
||||
val listener = AccountsChangeListener {
|
||||
trySendBlocking(completeAccounts)
|
||||
}
|
||||
addOnAccountsChangeListener(listener)
|
||||
|
||||
awaitClose {
|
||||
removeOnAccountsChangeListener(listener)
|
||||
}
|
||||
}.buffer(capacity = Channel.CONFLATED)
|
||||
.flowOn(backgroundDispatcher)
|
||||
}
|
||||
|
||||
fun newAccount(): LegacyAccount {
|
||||
val accountUuid = UUID.randomUUID().toString()
|
||||
return newAccount(accountUuid)
|
||||
}
|
||||
|
||||
fun newAccount(accountUuid: String): LegacyAccount {
|
||||
val account =
|
||||
LegacyAccount(accountUuid, K9::isSensitiveDebugLoggingEnabled)
|
||||
accountDefaultsProvider.applyDefaults(account)
|
||||
|
||||
synchronized(accountLock) {
|
||||
newAccount = account
|
||||
accountsMap!![account.uuid] = account
|
||||
accountsInOrder.add(account)
|
||||
}
|
||||
|
||||
return account
|
||||
}
|
||||
|
||||
fun deleteAccount(account: LegacyAccount) {
|
||||
synchronized(accountLock) {
|
||||
accountsMap?.remove(account.uuid)
|
||||
accountsInOrder.remove(account)
|
||||
|
||||
val storageEditor = createStorageEditor()
|
||||
legacyAccountStorageHandler.delete(account, storage, storageEditor)
|
||||
storageEditor.commit()
|
||||
|
||||
if (account === newAccount) {
|
||||
newAccount = null
|
||||
}
|
||||
}
|
||||
|
||||
notifyAccountRemovedListeners(account)
|
||||
notifyAccountsChangeListeners()
|
||||
}
|
||||
|
||||
val defaultAccount: LegacyAccount?
|
||||
get() = getAccounts().firstOrNull()
|
||||
|
||||
override fun saveAccount(account: LegacyAccount) {
|
||||
ensureAssignedAccountNumber(account)
|
||||
processChangedValues(account)
|
||||
|
||||
synchronized(accountLock) {
|
||||
val editor = createStorageEditor()
|
||||
legacyAccountStorageHandler.save(account, storage, editor)
|
||||
editor.commit()
|
||||
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
notifyAccountsChangeListeners()
|
||||
}
|
||||
|
||||
private fun ensureAssignedAccountNumber(account: LegacyAccount) {
|
||||
if (account.accountNumber != UNASSIGNED_ACCOUNT_NUMBER) return
|
||||
|
||||
account.accountNumber = generateAccountNumber()
|
||||
}
|
||||
|
||||
private fun processChangedValues(account: LegacyAccount) {
|
||||
if (account.isChangedVisibleLimits) {
|
||||
try {
|
||||
localStoreProvider.getInstance(account).resetVisibleLimits(account.displayCount)
|
||||
} catch (e: MessagingException) {
|
||||
Log.e(e, "Failed to load LocalStore!")
|
||||
}
|
||||
}
|
||||
account.resetChangeMarkers()
|
||||
}
|
||||
|
||||
fun generateAccountNumber(): Int {
|
||||
val accountNumbers = getAccounts().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: LegacyAccount, newPosition: Int) {
|
||||
synchronized(accountLock) {
|
||||
val storageEditor = createStorageEditor()
|
||||
moveToPosition(account, storage, storageEditor, newPosition)
|
||||
storageEditor.commit()
|
||||
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
notifyAccountsChangeListeners()
|
||||
}
|
||||
|
||||
private fun moveToPosition(account: LegacyAccount, storage: Storage, editor: StorageEditor, newPosition: Int) {
|
||||
val accountUuids = storage.getStringOrDefault("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 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: LegacyAccount) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
48
legacy/core/src/main/java/com/fsck/k9/QuietTimeChecker.kt
Normal file
48
legacy/core/src/main/java/com/fsck/k9/QuietTimeChecker.kt
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import java.util.Calendar
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
private const val MINUTES_PER_HOUR = 60
|
||||
class QuietTimeChecker
|
||||
@OptIn(ExperimentalTime::class)
|
||||
constructor(
|
||||
private val clock: Clock,
|
||||
quietTimeStart: String,
|
||||
quietTimeEnd: String,
|
||||
) {
|
||||
private val quietTimeStart: Int = parseTime(quietTimeStart)
|
||||
private val quietTimeEnd: Int = parseTime(quietTimeEnd)
|
||||
|
||||
val isQuietTime: Boolean
|
||||
get() {
|
||||
// If start and end times are the same, we're never quiet
|
||||
if (quietTimeStart == quietTimeEnd) {
|
||||
return false
|
||||
}
|
||||
|
||||
val calendar = Calendar.getInstance()
|
||||
@OptIn(ExperimentalTime::class)
|
||||
calendar.timeInMillis = clock.now().toEpochMilliseconds()
|
||||
|
||||
val minutesSinceMidnight =
|
||||
(calendar[Calendar.HOUR_OF_DAY] * MINUTES_PER_HOUR) + calendar[Calendar.MINUTE]
|
||||
|
||||
return if (quietTimeStart > quietTimeEnd) {
|
||||
minutesSinceMidnight >= quietTimeStart || minutesSinceMidnight <= quietTimeEnd
|
||||
} else {
|
||||
minutesSinceMidnight in quietTimeStart..quietTimeEnd
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun parseTime(time: String): Int {
|
||||
val parts = time.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||
val hour = parts[0].toInt()
|
||||
val minute = parts[1].toInt()
|
||||
|
||||
return hour * MINUTES_PER_HOUR + minute
|
||||
}
|
||||
}
|
||||
}
|
||||
44
legacy/core/src/main/java/com/fsck/k9/StrictMode.kt
Normal file
44
legacy/core/src/main/java/com/fsck/k9/StrictMode.kt
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import android.os.Build
|
||||
import android.os.StrictMode
|
||||
import android.os.StrictMode.ThreadPolicy
|
||||
import android.os.StrictMode.VmPolicy
|
||||
|
||||
fun enableStrictMode() {
|
||||
StrictMode.setThreadPolicy(createThreadPolicy())
|
||||
StrictMode.setVmPolicy(createVmPolicy())
|
||||
}
|
||||
|
||||
private fun createThreadPolicy(): ThreadPolicy {
|
||||
return ThreadPolicy.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createVmPolicy(): VmPolicy {
|
||||
return VmPolicy.Builder()
|
||||
.detectActivityLeaks()
|
||||
.detectLeakedClosableObjects()
|
||||
.detectLeakedRegistrationObjects()
|
||||
.detectFileUriExposure()
|
||||
.detectLeakedSqlLiteObjects()
|
||||
.apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
detectContentUriWithoutPermission()
|
||||
|
||||
// Disabled because we currently don't use tagged sockets; so this would generate a lot of noise
|
||||
// detectUntaggedSockets()
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
detectCredentialProtectedWhileLocked()
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
detectIncorrectContextUse()
|
||||
detectUnsafeIntentLaunch()
|
||||
}
|
||||
}
|
||||
.penaltyLog()
|
||||
.build()
|
||||
}
|
||||
7
legacy/core/src/main/java/com/fsck/k9/UiDensity.kt
Normal file
7
legacy/core/src/main/java/com/fsck/k9/UiDensity.kt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package com.fsck.k9
|
||||
|
||||
enum class UiDensity {
|
||||
Compact,
|
||||
Default,
|
||||
Relaxed,
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
package com.fsck.k9.autocrypt
|
||||
|
||||
import com.fsck.k9.message.CryptoStatus
|
||||
|
||||
data class AutocryptDraftStateHeader(
|
||||
val isEncrypt: Boolean,
|
||||
val isSignOnly: Boolean,
|
||||
val isReply: Boolean,
|
||||
val isByChoice: Boolean,
|
||||
val isPgpInline: Boolean,
|
||||
val parameters: Map<String, String> = mapOf(),
|
||||
) {
|
||||
|
||||
fun toHeaderValue(): String {
|
||||
val builder = StringBuilder()
|
||||
|
||||
builder.append(AutocryptDraftStateHeader.PARAM_ENCRYPT)
|
||||
builder.append(if (isEncrypt) "=yes; " else "=no; ")
|
||||
|
||||
if (isReply) {
|
||||
builder.append(AutocryptDraftStateHeader.PARAM_IS_REPLY).append("=yes; ")
|
||||
}
|
||||
if (isSignOnly) {
|
||||
builder.append(AutocryptDraftStateHeader.PARAM_SIGN_ONLY).append("=yes; ")
|
||||
}
|
||||
if (isByChoice) {
|
||||
builder.append(AutocryptDraftStateHeader.PARAM_BY_CHOICE).append("=yes; ")
|
||||
}
|
||||
if (isPgpInline) {
|
||||
builder.append(AutocryptDraftStateHeader.PARAM_PGP_INLINE).append("=yes; ")
|
||||
}
|
||||
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val AUTOCRYPT_DRAFT_STATE_HEADER = "Autocrypt-Draft-State"
|
||||
|
||||
const val PARAM_ENCRYPT = "encrypt"
|
||||
|
||||
const val PARAM_IS_REPLY = "_is-reply-to-encrypted"
|
||||
const val PARAM_BY_CHOICE = "_by-choice"
|
||||
const val PARAM_PGP_INLINE = "_pgp-inline"
|
||||
const val PARAM_SIGN_ONLY = "_sign-only"
|
||||
|
||||
const val VALUE_YES = "yes"
|
||||
|
||||
@JvmStatic
|
||||
fun fromCryptoStatus(cryptoStatus: CryptoStatus): AutocryptDraftStateHeader {
|
||||
if (cryptoStatus.isSignOnly) {
|
||||
return AutocryptDraftStateHeader(
|
||||
false,
|
||||
true,
|
||||
cryptoStatus.isReplyToEncrypted,
|
||||
cryptoStatus.isUserChoice(),
|
||||
cryptoStatus.isPgpInlineModeEnabled,
|
||||
mapOf(),
|
||||
)
|
||||
}
|
||||
return AutocryptDraftStateHeader(
|
||||
cryptoStatus.isEncryptionEnabled,
|
||||
false,
|
||||
cryptoStatus.isReplyToEncrypted,
|
||||
cryptoStatus.isUserChoice(),
|
||||
cryptoStatus.isPgpInlineModeEnabled,
|
||||
mapOf(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.fsck.k9.autocrypt
|
||||
|
||||
import com.fsck.k9.mail.internet.MimeUtility
|
||||
|
||||
class AutocryptDraftStateHeaderParser internal constructor() {
|
||||
|
||||
fun parseAutocryptDraftStateHeader(headerValue: String): AutocryptDraftStateHeader? {
|
||||
val parameters = MimeUtility.getAllHeaderParameters(headerValue)
|
||||
|
||||
val isEncryptStr = parameters.remove(AutocryptDraftStateHeader.PARAM_ENCRYPT) ?: return null
|
||||
val isEncrypt = isEncryptStr == AutocryptDraftStateHeader.VALUE_YES
|
||||
|
||||
val isSignOnlyStr = parameters.remove(AutocryptDraftStateHeader.PARAM_SIGN_ONLY)
|
||||
val isSignOnly = isSignOnlyStr == AutocryptDraftStateHeader.VALUE_YES
|
||||
|
||||
val isReplyStr = parameters.remove(AutocryptDraftStateHeader.PARAM_IS_REPLY)
|
||||
val isReply = isReplyStr == AutocryptDraftStateHeader.VALUE_YES
|
||||
|
||||
val isByChoiceStr = parameters.remove(AutocryptDraftStateHeader.PARAM_BY_CHOICE)
|
||||
val isByChoice = isByChoiceStr == AutocryptDraftStateHeader.VALUE_YES
|
||||
|
||||
val isPgpInlineStr = parameters.remove(AutocryptDraftStateHeader.PARAM_PGP_INLINE)
|
||||
val isPgpInline = isPgpInlineStr == AutocryptDraftStateHeader.VALUE_YES
|
||||
|
||||
if (hasCriticalParameters(parameters)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return AutocryptDraftStateHeader(isEncrypt, isSignOnly, isReply, isByChoice, isPgpInline, parameters)
|
||||
}
|
||||
|
||||
private fun hasCriticalParameters(parameters: Map<String, String>): Boolean {
|
||||
for (parameterName in parameters.keys) {
|
||||
if (!parameterName.startsWith("_")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package com.fsck.k9.autocrypt;
|
||||
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
|
||||
class AutocryptGossipHeader {
|
||||
static final String AUTOCRYPT_GOSSIP_HEADER = "Autocrypt-Gossip";
|
||||
|
||||
private static final String AUTOCRYPT_PARAM_ADDR = "addr";
|
||||
private static final String AUTOCRYPT_PARAM_KEY_DATA = "keydata";
|
||||
|
||||
|
||||
@NonNull
|
||||
final byte[] keyData;
|
||||
@NonNull
|
||||
final String addr;
|
||||
|
||||
AutocryptGossipHeader(@NonNull String addr, @NonNull byte[] keyData) {
|
||||
this.addr = addr;
|
||||
this.keyData = keyData;
|
||||
}
|
||||
|
||||
String toRawHeaderString() {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
|
||||
builder.append(AutocryptGossipHeader.AUTOCRYPT_GOSSIP_HEADER).append(": ");
|
||||
builder.append(AutocryptGossipHeader.AUTOCRYPT_PARAM_ADDR).append('=').append(addr).append("; ");
|
||||
builder.append(AutocryptGossipHeader.AUTOCRYPT_PARAM_KEY_DATA).append('=');
|
||||
builder.append(AutocryptHeader.createFoldedBase64KeyData(keyData));
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
AutocryptGossipHeader that = (AutocryptGossipHeader) o;
|
||||
|
||||
if (!Arrays.equals(keyData, that.keyData)) {
|
||||
return false;
|
||||
}
|
||||
return addr.equals(that.addr);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = Arrays.hashCode(keyData);
|
||||
result = 31 * result + addr.hashCode();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
package com.fsck.k9.autocrypt;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
|
||||
import com.fsck.k9.mail.Part;
|
||||
import com.fsck.k9.mail.internet.MimeUtility;
|
||||
import okio.ByteString;
|
||||
import net.thunderbird.core.logging.legacy.Log;
|
||||
|
||||
|
||||
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)) {
|
||||
Log.e("autocrypt: unsupported type parameter %s", type);
|
||||
return null;
|
||||
}
|
||||
|
||||
String base64KeyData = parameters.remove(AutocryptHeader.AUTOCRYPT_PARAM_KEY_DATA);
|
||||
if (base64KeyData == null) {
|
||||
Log.e("autocrypt: missing key parameter");
|
||||
return null;
|
||||
}
|
||||
|
||||
ByteString byteString = ByteString.decodeBase64(base64KeyData);
|
||||
if (byteString == null) {
|
||||
Log.e("autocrypt: error parsing base64 data");
|
||||
return null;
|
||||
}
|
||||
|
||||
String addr = parameters.remove(AutocryptHeader.AUTOCRYPT_PARAM_ADDR);
|
||||
if (addr == null) {
|
||||
Log.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) {
|
||||
Log.e("Encountered malformed autocrypt-gossip header - skipping!");
|
||||
continue;
|
||||
}
|
||||
autocryptHeaders.add(autocryptHeader);
|
||||
}
|
||||
return autocryptHeaders;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
package com.fsck.k9.autocrypt;
|
||||
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import okio.ByteString;
|
||||
|
||||
|
||||
class AutocryptHeader {
|
||||
static final String AUTOCRYPT_HEADER = "Autocrypt";
|
||||
|
||||
static final String AUTOCRYPT_PARAM_ADDR = "addr";
|
||||
static final String AUTOCRYPT_PARAM_KEY_DATA = "keydata";
|
||||
|
||||
static final String AUTOCRYPT_PARAM_TYPE = "type";
|
||||
static final String AUTOCRYPT_TYPE_1 = "1";
|
||||
|
||||
static final String AUTOCRYPT_PARAM_PREFER_ENCRYPT = "prefer-encrypt";
|
||||
static final String AUTOCRYPT_PREFER_ENCRYPT_MUTUAL = "mutual";
|
||||
|
||||
private static final int HEADER_LINE_LENGTH = 76;
|
||||
|
||||
|
||||
@NonNull
|
||||
final byte[] keyData;
|
||||
@NonNull
|
||||
final String addr;
|
||||
@NonNull
|
||||
final Map<String,String> parameters;
|
||||
final boolean isPreferEncryptMutual;
|
||||
|
||||
AutocryptHeader(@NonNull Map<String, String> parameters, @NonNull String addr,
|
||||
@NonNull byte[] keyData, boolean isPreferEncryptMutual) {
|
||||
this.parameters = parameters;
|
||||
this.addr = addr;
|
||||
this.keyData = keyData;
|
||||
this.isPreferEncryptMutual = isPreferEncryptMutual;
|
||||
}
|
||||
|
||||
String toRawHeaderString() {
|
||||
// TODO we don't properly fold lines here. if we want to support parameters, we need to do that somehow
|
||||
if (!parameters.isEmpty()) {
|
||||
throw new UnsupportedOperationException("arbitrary parameters not supported");
|
||||
}
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append(AutocryptHeader.AUTOCRYPT_HEADER).append(": ");
|
||||
builder.append(AutocryptHeader.AUTOCRYPT_PARAM_ADDR).append('=').append(addr).append("; ");
|
||||
if (isPreferEncryptMutual) {
|
||||
builder.append(AutocryptHeader.AUTOCRYPT_PARAM_PREFER_ENCRYPT)
|
||||
.append('=').append(AutocryptHeader.AUTOCRYPT_PREFER_ENCRYPT_MUTUAL).append("; ");
|
||||
}
|
||||
builder.append(AutocryptHeader.AUTOCRYPT_PARAM_KEY_DATA).append("=");
|
||||
builder.append(createFoldedBase64KeyData(keyData));
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
static String createFoldedBase64KeyData(byte[] keyData) {
|
||||
String base64KeyData = ByteString.of(keyData).base64();
|
||||
StringBuilder result = new StringBuilder();
|
||||
|
||||
for (int i = 0, base64Length = base64KeyData.length(); i < base64Length; i += HEADER_LINE_LENGTH) {
|
||||
if (i + HEADER_LINE_LENGTH <= base64Length) {
|
||||
result.append("\r\n ");
|
||||
result.append(base64KeyData, i, i + HEADER_LINE_LENGTH);
|
||||
} else {
|
||||
result.append("\r\n ");
|
||||
result.append(base64KeyData, i, base64Length);
|
||||
}
|
||||
}
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
AutocryptHeader that = (AutocryptHeader) o;
|
||||
|
||||
return isPreferEncryptMutual == that.isPreferEncryptMutual && Arrays.equals(keyData, that.keyData)
|
||||
&& addr.equals(that.addr) && parameters.equals(that.parameters);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = Arrays.hashCode(keyData);
|
||||
result = 31 * result + addr.hashCode();
|
||||
result = 31 * result + parameters.hashCode();
|
||||
result = 31 * result + (isPreferEncryptMutual ? 1 : 0);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
package com.fsck.k9.autocrypt;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Map;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
|
||||
import com.fsck.k9.mail.Message;
|
||||
import com.fsck.k9.mail.internet.MimeUtility;
|
||||
import okio.ByteString;
|
||||
import net.thunderbird.core.logging.legacy.Log;
|
||||
|
||||
|
||||
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)) {
|
||||
Log.e("autocrypt: unsupported type parameter %s", type);
|
||||
return null;
|
||||
}
|
||||
|
||||
String base64KeyData = parameters.remove(AutocryptHeader.AUTOCRYPT_PARAM_KEY_DATA);
|
||||
if (base64KeyData == null) {
|
||||
Log.e("autocrypt: missing key parameter");
|
||||
return null;
|
||||
}
|
||||
|
||||
ByteString byteString = ByteString.decodeBase64(base64KeyData);
|
||||
if (byteString == null) {
|
||||
Log.e("autocrypt: error parsing base64 data");
|
||||
return null;
|
||||
}
|
||||
|
||||
String to = parameters.remove(AutocryptHeader.AUTOCRYPT_PARAM_ADDR);
|
||||
if (to == null) {
|
||||
Log.e("autocrypt: no to header!");
|
||||
return null;
|
||||
}
|
||||
|
||||
boolean isPreferEncryptMutual = false;
|
||||
String preferEncrypt = parameters.remove(AutocryptHeader.AUTOCRYPT_PARAM_PREFER_ENCRYPT);
|
||||
if (AutocryptHeader.AUTOCRYPT_PREFER_ENCRYPT_MUTUAL.equalsIgnoreCase(preferEncrypt)) {
|
||||
isPreferEncryptMutual = true;
|
||||
}
|
||||
|
||||
if (hasCriticalParameters(parameters)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AutocryptHeader(parameters, to, byteString.toByteArray(), isPreferEncryptMutual);
|
||||
}
|
||||
|
||||
private boolean hasCriticalParameters(Map<String, String> parameters) {
|
||||
for (String parameterName : parameters.keySet()) {
|
||||
if (parameterName != null && !parameterName.startsWith("_")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private ArrayList<AutocryptHeader> parseAllAutocryptHeaders(String[] headers) {
|
||||
ArrayList<AutocryptHeader> autocryptHeaders = new ArrayList<>();
|
||||
for (String header : headers) {
|
||||
AutocryptHeader autocryptHeader = parseAutocryptHeader(header);
|
||||
if (autocryptHeader != null) {
|
||||
autocryptHeaders.add(autocryptHeader);
|
||||
}
|
||||
}
|
||||
return autocryptHeaders;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package com.fsck.k9.autocrypt;
|
||||
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
|
||||
import android.content.Intent;
|
||||
|
||||
import org.openintents.openpgp.util.OpenPgpApi;
|
||||
|
||||
|
||||
public class AutocryptOpenPgpApiInteractor {
|
||||
public static AutocryptOpenPgpApiInteractor getInstance() {
|
||||
return new AutocryptOpenPgpApiInteractor();
|
||||
}
|
||||
|
||||
private AutocryptOpenPgpApiInteractor() { }
|
||||
|
||||
public byte[] getKeyMaterialForKeyId(OpenPgpApi openPgpApi, long keyId, String minimizeForUserId) {
|
||||
Intent retrieveKeyIntent = new Intent(OpenPgpApi.ACTION_GET_KEY);
|
||||
retrieveKeyIntent.putExtra(OpenPgpApi.EXTRA_KEY_ID, keyId);
|
||||
return getKeyMaterialFromApi(openPgpApi, retrieveKeyIntent, minimizeForUserId);
|
||||
}
|
||||
|
||||
public byte[] getKeyMaterialForUserId(OpenPgpApi openPgpApi, String userId) {
|
||||
Intent retrieveKeyIntent = new Intent(OpenPgpApi.ACTION_GET_KEY);
|
||||
retrieveKeyIntent.putExtra(OpenPgpApi.EXTRA_USER_ID, userId);
|
||||
return getKeyMaterialFromApi(openPgpApi, retrieveKeyIntent, userId);
|
||||
}
|
||||
|
||||
private byte[] getKeyMaterialFromApi(OpenPgpApi openPgpApi, Intent retrieveKeyIntent, String userId) {
|
||||
retrieveKeyIntent.putExtra(OpenPgpApi.EXTRA_MINIMIZE, true);
|
||||
retrieveKeyIntent.putExtra(OpenPgpApi.EXTRA_MINIMIZE_USER_ID, userId);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
Intent result = openPgpApi.executeApi(retrieveKeyIntent, (InputStream) null, baos);
|
||||
|
||||
if (result.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR) ==
|
||||
OpenPgpApi.RESULT_CODE_SUCCESS) {
|
||||
return baos.toByteArray();
|
||||
} else{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
package com.fsck.k9.autocrypt;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.fsck.k9.mail.Address;
|
||||
import com.fsck.k9.mail.Message;
|
||||
import com.fsck.k9.mail.Message.RecipientType;
|
||||
import com.fsck.k9.mail.internet.MimeBodyPart;
|
||||
import org.openintents.openpgp.AutocryptPeerUpdate;
|
||||
import org.openintents.openpgp.util.OpenPgpApi;
|
||||
|
||||
|
||||
public class AutocryptOperations {
|
||||
private final AutocryptHeaderParser autocryptHeaderParser;
|
||||
private final AutocryptGossipHeaderParser autocryptGossipHeaderParser;
|
||||
|
||||
|
||||
public static AutocryptOperations getInstance() {
|
||||
AutocryptHeaderParser autocryptHeaderParser = AutocryptHeaderParser.getInstance();
|
||||
AutocryptGossipHeaderParser autocryptGossipHeaderParser = AutocryptGossipHeaderParser.getInstance();
|
||||
return new AutocryptOperations(autocryptHeaderParser, autocryptGossipHeaderParser);
|
||||
}
|
||||
|
||||
|
||||
private AutocryptOperations(AutocryptHeaderParser autocryptHeaderParser,
|
||||
AutocryptGossipHeaderParser autocryptGossipHeaderParser) {
|
||||
this.autocryptHeaderParser = autocryptHeaderParser;
|
||||
this.autocryptGossipHeaderParser = autocryptGossipHeaderParser;
|
||||
}
|
||||
|
||||
public boolean addAutocryptPeerUpdateToIntentIfPresent(Message currentMessage, Intent intent) {
|
||||
AutocryptHeader autocryptHeader = autocryptHeaderParser.getValidAutocryptHeader(currentMessage);
|
||||
if (autocryptHeader == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String messageFromAddress = currentMessage.getFrom()[0].getAddress();
|
||||
if (!autocryptHeader.addr.equalsIgnoreCase(messageFromAddress)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Date messageDate = currentMessage.getSentDate();
|
||||
Date internalDate = currentMessage.getInternalDate();
|
||||
Date effectiveDate = messageDate.before(internalDate) ? messageDate : internalDate;
|
||||
|
||||
AutocryptPeerUpdate data = AutocryptPeerUpdate.create(
|
||||
autocryptHeader.keyData, effectiveDate, autocryptHeader.isPreferEncryptMutual);
|
||||
intent.putExtra(OpenPgpApi.EXTRA_AUTOCRYPT_PEER_ID, messageFromAddress);
|
||||
intent.putExtra(OpenPgpApi.EXTRA_AUTOCRYPT_PEER_UPDATE, data);
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean addAutocryptGossipUpdateToIntentIfPresent(Message message, MimeBodyPart decryptedPart, Intent intent) {
|
||||
Bundle updates = createGossipUpdateBundle(message, decryptedPart);
|
||||
|
||||
if (updates == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
intent.putExtra(OpenPgpApi.EXTRA_AUTOCRYPT_PEER_GOSSIP_UPDATES, updates);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private Bundle createGossipUpdateBundle(Message message, MimeBodyPart decryptedPart) {
|
||||
List<String> gossipAcceptedAddresses = getGossipAcceptedAddresses(message);
|
||||
if (gossipAcceptedAddresses.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<AutocryptGossipHeader> autocryptGossipHeaders =
|
||||
autocryptGossipHeaderParser.getAllAutocryptGossipHeaders(decryptedPart);
|
||||
if (autocryptGossipHeaders.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Date messageDate = message.getSentDate();
|
||||
Date internalDate = message.getInternalDate();
|
||||
Date effectiveDate = messageDate.before(internalDate) ? messageDate : internalDate;
|
||||
|
||||
return createGossipUpdateBundle(gossipAcceptedAddresses, autocryptGossipHeaders, effectiveDate);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private Bundle createGossipUpdateBundle(List<String> gossipAcceptedAddresses,
|
||||
List<AutocryptGossipHeader> autocryptGossipHeaders, Date effectiveDate) {
|
||||
Bundle updates = new Bundle();
|
||||
for (AutocryptGossipHeader autocryptGossipHeader : autocryptGossipHeaders) {
|
||||
String normalizedAddress = autocryptGossipHeader.addr.toLowerCase(Locale.ROOT);
|
||||
boolean isAcceptedAddress = gossipAcceptedAddresses.contains(normalizedAddress);
|
||||
if (!isAcceptedAddress) {
|
||||
continue;
|
||||
}
|
||||
|
||||
AutocryptPeerUpdate update = AutocryptPeerUpdate.create(autocryptGossipHeader.keyData, effectiveDate, false);
|
||||
updates.putParcelable(autocryptGossipHeader.addr, update);
|
||||
}
|
||||
if (updates.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return updates;
|
||||
}
|
||||
|
||||
private List<String> getGossipAcceptedAddresses(Message message) {
|
||||
ArrayList<String> result = new ArrayList<>();
|
||||
|
||||
addRecipientsToList(result, message, RecipientType.TO);
|
||||
addRecipientsToList(result, message, RecipientType.CC);
|
||||
removeRecipientsFromList(result, message, RecipientType.DELIVERED_TO);
|
||||
|
||||
return Collections.unmodifiableList(result);
|
||||
}
|
||||
|
||||
private void addRecipientsToList(ArrayList<String> result, Message message, RecipientType recipientType) {
|
||||
for (Address address : message.getRecipients(recipientType)) {
|
||||
String addr = address.getAddress();
|
||||
if (addr != null) {
|
||||
result.add(addr.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void removeRecipientsFromList(ArrayList<String> result, Message message, RecipientType recipientType) {
|
||||
for (Address address : message.getRecipients(recipientType)) {
|
||||
String addr = address.getAddress();
|
||||
if (addr != null) {
|
||||
result.remove(addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean hasAutocryptHeader(Message currentMessage) {
|
||||
return currentMessage.getHeader(AutocryptHeader.AUTOCRYPT_HEADER).length > 0;
|
||||
}
|
||||
|
||||
public boolean hasAutocryptGossipHeader(MimeBodyPart part) {
|
||||
return part.getHeader(AutocryptGossipHeader.AUTOCRYPT_GOSSIP_HEADER).length > 0;
|
||||
}
|
||||
|
||||
public void addAutocryptHeaderToMessage(Message message, byte[] keyData,
|
||||
String autocryptAddress, boolean preferEncryptMutual) {
|
||||
AutocryptHeader autocryptHeader = new AutocryptHeader(
|
||||
Collections.<String,String>emptyMap(), autocryptAddress, keyData, preferEncryptMutual);
|
||||
String rawAutocryptHeader = autocryptHeader.toRawHeaderString();
|
||||
|
||||
message.addRawHeader(AutocryptHeader.AUTOCRYPT_HEADER, rawAutocryptHeader);
|
||||
}
|
||||
|
||||
public void addAutocryptGossipHeaderToPart(MimeBodyPart part, byte[] keyData, String autocryptAddress) {
|
||||
AutocryptGossipHeader autocryptGossipHeader = new AutocryptGossipHeader(autocryptAddress, keyData);
|
||||
String rawAutocryptHeader = autocryptGossipHeader.toRawHeaderString();
|
||||
|
||||
part.addRawHeader(AutocryptGossipHeader.AUTOCRYPT_GOSSIP_HEADER, rawAutocryptHeader);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.fsck.k9.autocrypt
|
||||
|
||||
interface AutocryptStringProvider {
|
||||
fun transferMessageSubject(): String
|
||||
fun transferMessageBody(): String
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
package com.fsck.k9.autocrypt
|
||||
|
||||
import com.fsck.k9.mail.Address
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.Message
|
||||
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
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
import net.thunderbird.core.preference.GeneralSettingsManager
|
||||
|
||||
class AutocryptTransferMessageCreator(
|
||||
private val stringProvider: AutocryptStringProvider,
|
||||
private val generalSettingsManager: GeneralSettingsManager,
|
||||
) {
|
||||
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,
|
||||
generalSettingsManager.getSettings().privacy.isHideTimeZone,
|
||||
)
|
||||
message.setFrom(address)
|
||||
message.setHeader("To", address.toEncodedString())
|
||||
|
||||
return message
|
||||
} catch (e: MessagingException) {
|
||||
throw AssertionError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.fsck.k9.autocrypt
|
||||
|
||||
import org.koin.dsl.module
|
||||
|
||||
val autocryptModule = module {
|
||||
single {
|
||||
AutocryptTransferMessageCreator(
|
||||
stringProvider = get(),
|
||||
generalSettingsManager = get(),
|
||||
)
|
||||
}
|
||||
single { AutocryptDraftStateHeaderParser() }
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.fsck.k9.backend
|
||||
|
||||
import com.fsck.k9.backend.api.Backend
|
||||
import net.thunderbird.backend.api.BackendFactory
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
|
||||
@Deprecated(
|
||||
message = "Use net.thunderbird.backend.api.BackendFactory<TAccount : BaseAccount> instead",
|
||||
replaceWith = ReplaceWith(
|
||||
expression = "BackendFactory<LegacyAccount>",
|
||||
"net.thunderbird.backend.api.BackendFactory",
|
||||
"net.thunderbird.core.android.account.LegacyAccount",
|
||||
),
|
||||
)
|
||||
interface BackendFactory : BackendFactory<LegacyAccount> {
|
||||
override fun createBackend(account: LegacyAccount): Backend
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
package com.fsck.k9.backend
|
||||
|
||||
import com.fsck.k9.backend.api.Backend
|
||||
import com.fsck.k9.mail.ServerSettings
|
||||
import java.util.concurrent.CopyOnWriteArraySet
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
|
||||
class BackendManager(private val backendFactories: Map<String, BackendFactory>) {
|
||||
private val backendCache = mutableMapOf<String, BackendContainer>()
|
||||
private val listeners = CopyOnWriteArraySet<BackendChangedListener>()
|
||||
|
||||
fun getBackend(account: LegacyAccount): 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: LegacyAccount): Boolean {
|
||||
return container.incomingServerSettings == account.incomingServerSettings &&
|
||||
container.outgoingServerSettings == account.outgoingServerSettings
|
||||
}
|
||||
|
||||
fun removeBackend(account: LegacyAccount) {
|
||||
synchronized(backendCache) {
|
||||
backendCache.remove(account.uuid)
|
||||
}
|
||||
|
||||
notifyListeners(account)
|
||||
}
|
||||
|
||||
private fun createBackend(account: LegacyAccount): 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: LegacyAccount) {
|
||||
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: LegacyAccount)
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
package com.fsck.k9.controller
|
||||
|
||||
import app.k9mail.legacy.message.controller.MessageReference
|
||||
import com.fsck.k9.controller.MessagingController.MessageActor
|
||||
import com.fsck.k9.controller.MessagingController.MoveOrCopyFlavor
|
||||
import com.fsck.k9.mailstore.LocalFolder
|
||||
import com.fsck.k9.mailstore.LocalMessage
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
import net.thunderbird.core.featureflag.FeatureFlagProvider
|
||||
import net.thunderbird.core.featureflag.toFeatureFlagKey
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
|
||||
internal class ArchiveOperations(
|
||||
private val messagingController: MessagingController,
|
||||
private val featureFlagProvider: FeatureFlagProvider,
|
||||
) {
|
||||
fun archiveThreads(messages: List<MessageReference>) {
|
||||
archiveByFolder("archiveThreads", messages) { account, folderId, messagesInFolder, archiveFolderId ->
|
||||
archiveThreads(account, folderId, messagesInFolder, archiveFolderId)
|
||||
}
|
||||
}
|
||||
|
||||
fun archiveMessages(messages: List<MessageReference>) {
|
||||
archiveByFolder("archiveMessages", messages) { account, folderId, messagesInFolder, archiveFolderId ->
|
||||
archiveMessages(account, folderId, messagesInFolder, archiveFolderId)
|
||||
}
|
||||
}
|
||||
|
||||
fun archiveMessage(message: MessageReference) {
|
||||
archiveMessages(listOf(message))
|
||||
}
|
||||
|
||||
private fun archiveByFolder(
|
||||
description: String,
|
||||
messages: List<MessageReference>,
|
||||
action: (
|
||||
account: LegacyAccount,
|
||||
folderId: Long,
|
||||
messagesInFolder: List<LocalMessage>,
|
||||
archiveFolderId: Long,
|
||||
) -> Unit,
|
||||
) {
|
||||
actOnMessagesGroupedByAccountAndFolder(messages) { account, messageFolder, messagesInFolder ->
|
||||
val sourceFolderId = messageFolder.databaseId
|
||||
when (val archiveFolderId = account.archiveFolderId) {
|
||||
null -> {
|
||||
Log.v("No archive folder configured for account %s", account)
|
||||
}
|
||||
|
||||
sourceFolderId -> {
|
||||
Log.v("Skipping messages already in archive folder")
|
||||
}
|
||||
|
||||
else -> {
|
||||
messagingController.suppressMessages(account, messagesInFolder)
|
||||
messagingController.putBackground(description, null) {
|
||||
action(account, sourceFolderId, messagesInFolder, archiveFolderId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun archiveThreads(
|
||||
account: LegacyAccount,
|
||||
sourceFolderId: Long,
|
||||
messages: List<LocalMessage>,
|
||||
archiveFolderId: Long,
|
||||
) {
|
||||
val messagesInThreads = messagingController.collectMessagesInThreads(account, messages)
|
||||
archiveMessages(account, sourceFolderId, messagesInThreads, archiveFolderId)
|
||||
}
|
||||
|
||||
private fun archiveMessages(
|
||||
account: LegacyAccount,
|
||||
sourceFolderId: Long,
|
||||
messages: List<LocalMessage>,
|
||||
archiveFolderId: Long,
|
||||
) {
|
||||
val operation = featureFlagProvider.provide("archive_marks_as_read".toFeatureFlagKey())
|
||||
.whenEnabledOrNot(
|
||||
onEnabled = { MoveOrCopyFlavor.MOVE_AND_MARK_AS_READ },
|
||||
onDisabledOrUnavailable = { MoveOrCopyFlavor.MOVE },
|
||||
)
|
||||
messagingController.moveOrCopyMessageSynchronous(
|
||||
account,
|
||||
sourceFolderId,
|
||||
messages,
|
||||
archiveFolderId,
|
||||
operation,
|
||||
)
|
||||
}
|
||||
|
||||
private fun actOnMessagesGroupedByAccountAndFolder(
|
||||
messages: List<MessageReference>,
|
||||
block: (account: LegacyAccount, messageFolder: LocalFolder, messages: List<LocalMessage>) -> Unit,
|
||||
) {
|
||||
val actor = MessageActor { account, messageFolder, messagesInFolder ->
|
||||
block(account, messageFolder, messagesInFolder)
|
||||
}
|
||||
|
||||
messagingController.actOnMessagesGroupedByAccountAndFolder(messages, actor)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.fsck.k9.controller
|
||||
|
||||
import app.k9mail.legacy.message.controller.MessagingListener
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
package com.fsck.k9.controller
|
||||
|
||||
import app.k9mail.legacy.mailstore.MessageStoreManager
|
||||
import app.k9mail.legacy.message.controller.MessageCounts
|
||||
import app.k9mail.legacy.message.controller.MessageCountsProvider
|
||||
import app.k9mail.legacy.message.controller.MessagingControllerRegistry
|
||||
import app.k9mail.legacy.message.controller.SimpleMessagingListener
|
||||
import com.fsck.k9.search.excludeSpecialFolders
|
||||
import com.fsck.k9.search.getAccounts
|
||||
import com.fsck.k9.search.limitToDisplayableFolders
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import net.thunderbird.core.android.account.AccountManager
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import net.thunderbird.feature.mail.folder.api.OutboxFolderManager
|
||||
import net.thunderbird.feature.search.legacy.LocalMessageSearch
|
||||
import net.thunderbird.feature.search.legacy.SearchAccount
|
||||
import net.thunderbird.feature.search.legacy.SearchConditionTreeNode
|
||||
|
||||
internal class DefaultMessageCountsProvider(
|
||||
private val accountManager: AccountManager,
|
||||
private val messageStoreManager: MessageStoreManager,
|
||||
private val messagingControllerRegistry: MessagingControllerRegistry,
|
||||
private val outboxFolderManager: OutboxFolderManager,
|
||||
private val coroutineContext: CoroutineContext = Dispatchers.IO,
|
||||
) : MessageCountsProvider {
|
||||
override fun getMessageCounts(account: LegacyAccount): MessageCounts {
|
||||
val search = LocalMessageSearch().apply {
|
||||
excludeSpecialFolders(account, outboxFolderId = outboxFolderManager.getOutboxFolderIdSync(account.id))
|
||||
limitToDisplayableFolders()
|
||||
}
|
||||
|
||||
return getMessageCounts(account, search.conditions)
|
||||
}
|
||||
|
||||
override fun getMessageCounts(searchAccount: SearchAccount): MessageCounts {
|
||||
return getMessageCounts(searchAccount.relatedSearch)
|
||||
}
|
||||
|
||||
override fun getMessageCounts(search: LocalMessageSearch): MessageCounts {
|
||||
val accounts = search.getAccounts(accountManager)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override fun getUnreadMessageCount(account: LegacyAccount, folderId: Long): Int {
|
||||
return try {
|
||||
val messageStore = messageStoreManager.getMessageStore(account)
|
||||
val outboxFolderId = outboxFolderManager.getOutboxFolderIdSync(account.id)
|
||||
return if (folderId == outboxFolderId) {
|
||||
messageStore.getMessageCount(folderId)
|
||||
} else {
|
||||
messageStore.getUnreadMessageCount(folderId)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(e, "Unable to getUnreadMessageCount for account: %s, folder: %d", account, folderId)
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
override fun getMessageCountsFlow(search: LocalMessageSearch): Flow<MessageCounts> {
|
||||
return callbackFlow {
|
||||
send(getMessageCounts(search))
|
||||
|
||||
val folderStatusChangedListener = object : SimpleMessagingListener() {
|
||||
override fun folderStatusChanged(account: LegacyAccount, folderId: Long) {
|
||||
trySendBlocking(getMessageCounts(search))
|
||||
}
|
||||
}
|
||||
messagingControllerRegistry.addListener(folderStatusChangedListener)
|
||||
|
||||
awaitClose {
|
||||
messagingControllerRegistry.removeListener(folderStatusChangedListener)
|
||||
}
|
||||
}.buffer(capacity = Channel.CONFLATED)
|
||||
.distinctUntilChanged()
|
||||
.flowOn(coroutineContext)
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
private fun getMessageCounts(account: LegacyAccount, conditions: SearchConditionTreeNode?): MessageCounts {
|
||||
return try {
|
||||
val messageStore = messageStoreManager.getMessageStore(account)
|
||||
return MessageCounts(
|
||||
unread = messageStore.getUnreadMessageCount(conditions),
|
||||
starred = messageStore.getStarredMessageCount(conditions),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(e, "Unable to getMessageCounts for account: %s", account)
|
||||
MessageCounts(unread = 0, starred = 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
package com.fsck.k9.controller
|
||||
|
||||
import app.k9mail.legacy.mailstore.MessageStoreManager
|
||||
import app.k9mail.legacy.mailstore.SaveMessageData
|
||||
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.mailstore.LocalFolder
|
||||
import com.fsck.k9.mailstore.LocalMessage
|
||||
import com.fsck.k9.mailstore.SaveMessageDataCreator
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import org.jetbrains.annotations.NotNull
|
||||
|
||||
internal class DraftOperations(
|
||||
private val messagingController: @NotNull MessagingController,
|
||||
private val messageStoreManager: @NotNull MessageStoreManager,
|
||||
private val saveMessageDataCreator: SaveMessageDataCreator,
|
||||
) {
|
||||
|
||||
fun saveDraft(
|
||||
account: LegacyAccount,
|
||||
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) {
|
||||
Log.e(e, "Unable to save message as draft.")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveAndUploadDraft(
|
||||
account: LegacyAccount,
|
||||
message: Message,
|
||||
folderId: Long,
|
||||
existingDraftId: Long?,
|
||||
subject: String?,
|
||||
): Long {
|
||||
val messageStore = messageStoreManager.getMessageStore(account)
|
||||
|
||||
val messageId = messageStore.saveLocalMessage(folderId, message.toSaveMessageData(subject))
|
||||
|
||||
val previousDraftMessage = existingDraftId?.let {
|
||||
val localStore = messagingController.getLocalStoreOrThrow(account)
|
||||
val localFolder = localStore.getFolder(folderId)
|
||||
localFolder.open()
|
||||
|
||||
localFolder.getMessage(existingDraftId)
|
||||
}
|
||||
|
||||
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: LegacyAccount,
|
||||
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: LegacyAccount) {
|
||||
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) {
|
||||
Log.w("Couldn't find local copy of message to upload [ID: %d]", uploadMessageId)
|
||||
return
|
||||
} else if (!localMessage.uid.startsWith(K9.LOCAL_UID_PREFIX)) {
|
||||
Log.i("Message [ID: %d] to be uploaded already has a server ID set. Skipping upload.", uploadMessageId)
|
||||
} else {
|
||||
uploadMessage(backend, account, localFolder, localMessage)
|
||||
}
|
||||
|
||||
deleteMessage(backend, localFolder, command.deleteMessageId)
|
||||
}
|
||||
|
||||
private fun uploadMessage(
|
||||
backend: Backend,
|
||||
account: LegacyAccount,
|
||||
localFolder: LocalFolder,
|
||||
localMessage: LocalMessage,
|
||||
) {
|
||||
val folderServerId = localFolder.serverId
|
||||
Log.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) {
|
||||
Log.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, localFolder: LocalFolder, messageId: Long) {
|
||||
val messageServerId = localFolder.getMessageUidById(messageId) ?: run {
|
||||
Log.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)
|
||||
|
||||
messagingController.destroyPlaceholderMessages(localFolder, messageServerIds)
|
||||
}
|
||||
|
||||
private fun Message.toSaveMessageData(subject: String?): SaveMessageData {
|
||||
return saveMessageDataCreator.createSaveMessageData(this, MessageDownloadState.FULL, subject)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package com.fsck.k9.controller
|
||||
|
||||
import android.content.Context
|
||||
import app.k9mail.legacy.mailstore.MessageStoreManager
|
||||
import app.k9mail.legacy.message.controller.MessageCountsProvider
|
||||
import app.k9mail.legacy.message.controller.MessagingControllerRegistry
|
||||
import com.fsck.k9.Preferences
|
||||
import com.fsck.k9.backend.BackendManager
|
||||
import com.fsck.k9.mailstore.LocalStoreProvider
|
||||
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 net.thunderbird.core.featureflag.FeatureFlagProvider
|
||||
import net.thunderbird.core.logging.Logger
|
||||
import net.thunderbird.feature.mail.folder.api.OutboxFolderManager
|
||||
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<LocalDeleteOperationDecider>(),
|
||||
get(named("controllerExtensions")),
|
||||
get<FeatureFlagProvider>(),
|
||||
get<Logger>(named("syncDebug")),
|
||||
get<OutboxFolderManager>(),
|
||||
)
|
||||
}
|
||||
|
||||
single<MessagingControllerRegistry> { get<MessagingController>() }
|
||||
|
||||
single<MessageCountsProvider> {
|
||||
DefaultMessageCountsProvider(
|
||||
accountManager = get(),
|
||||
messageStoreManager = get(),
|
||||
messagingControllerRegistry = get(),
|
||||
outboxFolderManager = get(),
|
||||
)
|
||||
}
|
||||
|
||||
single { LocalDeleteOperationDecider() }
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.fsck.k9.controller
|
||||
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
|
||||
/**
|
||||
* Decides whether deleting a message in the app moves it to the trash folder or deletes it immediately.
|
||||
*
|
||||
* Note: This only applies to local messages. What remote operation is performed when deleting a message is controlled
|
||||
* by [LegacyAccount.deletePolicy].
|
||||
*/
|
||||
internal class LocalDeleteOperationDecider {
|
||||
fun isDeleteImmediately(account: LegacyAccount, folderId: Long): Boolean {
|
||||
// If there's no trash folder configured, all messages are deleted immediately.
|
||||
if (!account.hasTrashFolder()) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Deleting messages from the trash folder will delete them immediately.
|
||||
val isTrashFolder = folderId == account.trashFolderId
|
||||
|
||||
// Messages deleted from the spam folder are deleted immediately.
|
||||
val isSpamFolder = folderId == account.spamFolderId
|
||||
|
||||
return isTrashFolder || isSpamFolder
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
package com.fsck.k9.controller;
|
||||
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import java.util.Map.Entry;
|
||||
import app.k9mail.legacy.message.controller.MessagingListener;
|
||||
import app.k9mail.legacy.message.controller.SimpleMessagingListener;
|
||||
import net.thunderbird.core.android.account.LegacyAccount;
|
||||
|
||||
|
||||
class MemorizingMessagingListener extends SimpleMessagingListener {
|
||||
Map<String, Memory> memories = new HashMap<>(31);
|
||||
|
||||
synchronized void removeAccount(LegacyAccount 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(LegacyAccount account, long folderId) {
|
||||
Memory memory = getMemory(account, folderId);
|
||||
memory.syncingState = MemorizingState.STARTED;
|
||||
memory.folderCompleted = 0;
|
||||
memory.folderTotal = 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void synchronizeMailboxFinished(LegacyAccount account, long folderId) {
|
||||
Memory memory = getMemory(account, folderId);
|
||||
memory.syncingState = MemorizingState.FINISHED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void synchronizeMailboxFailed(LegacyAccount account, long folderId,
|
||||
String message) {
|
||||
|
||||
Memory memory = getMemory(account, folderId);
|
||||
memory.syncingState = MemorizingState.FAILED;
|
||||
memory.failureMessage = message;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void synchronizeMailboxProgress(LegacyAccount account, long folderId, int completed,
|
||||
int total) {
|
||||
Memory memory = getMemory(account, folderId);
|
||||
memory.folderCompleted = completed;
|
||||
memory.folderTotal = total;
|
||||
}
|
||||
|
||||
private Memory getMemory(LegacyAccount 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(LegacyAccount account, long folderId) {
|
||||
return account.getUuid() + ":" + folderId;
|
||||
}
|
||||
|
||||
private enum MemorizingState { STARTED, FINISHED, FAILED }
|
||||
|
||||
private static class Memory {
|
||||
LegacyAccount account;
|
||||
long folderId;
|
||||
MemorizingState syncingState = null;
|
||||
String failureMessage = null;
|
||||
|
||||
int folderCompleted = 0;
|
||||
int folderTotal = 0;
|
||||
|
||||
Memory(LegacyAccount account, long folderId) {
|
||||
this.account = account;
|
||||
this.folderId = folderId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package com.fsck.k9.controller;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import app.k9mail.legacy.message.controller.MessageReference;
|
||||
import net.thunderbird.core.logging.legacy.Log;
|
||||
|
||||
|
||||
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 {
|
||||
Log.w("Invalid message reference: %s", messageReferenceString);
|
||||
}
|
||||
}
|
||||
|
||||
return messageReferences;
|
||||
}
|
||||
|
||||
public static ArrayList<String> toMessageReferenceStringList(List<MessageReference> messageReferences) {
|
||||
ArrayList<String> messageReferenceStrings = new ArrayList<>(messageReferences.size());
|
||||
for (MessageReference messageReference : messageReferences) {
|
||||
String messageReferenceString = messageReference.toIdentityString();
|
||||
messageReferenceStrings.add(messageReferenceString);
|
||||
}
|
||||
|
||||
return messageReferenceStrings;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,285 @@
|
|||
package com.fsck.k9.controller;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.fsck.k9.mail.Flag;
|
||||
import net.thunderbird.core.common.exception.MessagingException;
|
||||
import net.thunderbird.core.android.account.LegacyAccount;
|
||||
|
||||
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_SPAM = "empty_spam";
|
||||
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, LegacyAccount 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, LegacyAccount 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, LegacyAccount account) throws MessagingException {
|
||||
controller.processPendingMoveAndRead(this, account);
|
||||
}
|
||||
}
|
||||
|
||||
public static class PendingEmptySpam extends PendingCommand {
|
||||
public static PendingEmptySpam create() {
|
||||
return new PendingEmptySpam();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCommandName() {
|
||||
return COMMAND_EMPTY_SPAM;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(MessagingController controller, LegacyAccount account) throws MessagingException {
|
||||
controller.processPendingEmptySpam(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, LegacyAccount 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, LegacyAccount 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, LegacyAccount 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, LegacyAccount 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, LegacyAccount 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, LegacyAccount 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, LegacyAccount account) throws MessagingException {
|
||||
controller.processPendingExpunge(this, account);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
package com.fsck.k9.controller
|
||||
|
||||
import app.k9mail.legacy.mailstore.MessageStoreManager
|
||||
import com.fsck.k9.notification.NotificationController
|
||||
import com.fsck.k9.search.isNewMessages
|
||||
import com.fsck.k9.search.isSingleFolder
|
||||
import com.fsck.k9.search.isUnifiedInbox
|
||||
import net.thunderbird.core.android.account.AccountManager
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
import net.thunderbird.feature.search.legacy.LocalMessageSearch
|
||||
|
||||
internal class NotificationOperations(
|
||||
private val notificationController: NotificationController,
|
||||
private val accountManager: AccountManager,
|
||||
private val messageStoreManager: MessageStoreManager,
|
||||
) {
|
||||
fun clearNotifications(search: LocalMessageSearch) {
|
||||
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 accountManager.getAccounts()) {
|
||||
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 accountManager.getAccounts()) {
|
||||
notificationController.clearNewMailNotifications(account, clearNewMessageState = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearNotifications(account: LegacyAccount, folderId: Long) {
|
||||
notificationController.clearNewMailNotifications(account) { messageReferences ->
|
||||
messageReferences.filter { messageReference -> messageReference.folderId == folderId }
|
||||
}
|
||||
}
|
||||
|
||||
private fun LocalMessageSearch.firstAccount(): LegacyAccount? {
|
||||
return accountManager.getAccount(accountUuids.first())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.fsck.k9.controller
|
||||
|
||||
class NotificationState {
|
||||
@get:JvmName("wasNotified")
|
||||
var wasNotified: Boolean = false
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
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.PendingEmptySpam;
|
||||
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_SPAM, moshi.adapter(PendingEmptySpam.class));
|
||||
adapters.put(MessagingControllerCommands.COMMAND_EMPTY_TRASH, moshi.adapter(PendingEmptyTrash.class));
|
||||
adapters.put(MessagingControllerCommands.COMMAND_EXPUNGE, moshi.adapter(PendingExpunge.class));
|
||||
adapters.put(MessagingControllerCommands.COMMAND_MARK_ALL_AS_READ, moshi.adapter(PendingMarkAllAsRead.class));
|
||||
adapters.put(MessagingControllerCommands.COMMAND_SET_FLAG, moshi.adapter(PendingSetFlag.class));
|
||||
adapters.put(MessagingControllerCommands.COMMAND_DELETE, moshi.adapter(PendingDelete.class));
|
||||
|
||||
this.adapters = Collections.unmodifiableMap(adapters);
|
||||
}
|
||||
|
||||
|
||||
public static PendingCommandSerializer getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
|
||||
public <T extends PendingCommand> String serialize(T command) {
|
||||
// noinspection unchecked, we know the map has correctly matching adapters
|
||||
JsonAdapter<T> adapter = (JsonAdapter<T>) adapters.get(command.getCommandName());
|
||||
if (adapter == null) {
|
||||
throw new IllegalArgumentException("Unsupported pending command type!");
|
||||
}
|
||||
return adapter.toJson(command);
|
||||
}
|
||||
|
||||
public PendingCommand unserialize(long databaseId, String commandName, String data) {
|
||||
JsonAdapter<? extends PendingCommand> adapter = adapters.get(commandName);
|
||||
if (adapter == null) {
|
||||
throw new IllegalArgumentException("Unsupported pending command type!");
|
||||
}
|
||||
try {
|
||||
PendingCommand command = adapter.fromJson(data);
|
||||
command.databaseId = databaseId;
|
||||
return command;
|
||||
} catch (IOException e) {
|
||||
throw new IOError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
@file:JvmName("Preconditions")
|
||||
|
||||
package com.fsck.k9.controller
|
||||
|
||||
import com.fsck.k9.K9
|
||||
|
||||
fun <T : Any> requireNotNull(value: T?) {
|
||||
kotlin.requireNotNull(value)
|
||||
}
|
||||
|
||||
fun requireValidUids(uidMap: Map<String?, String?>?) {
|
||||
kotlin.requireNotNull(uidMap)
|
||||
for ((sourceUid, destinationUid) in uidMap) {
|
||||
requireNotLocalUid(sourceUid)
|
||||
kotlin.requireNotNull(destinationUid)
|
||||
}
|
||||
}
|
||||
|
||||
fun requireValidUids(uids: List<String?>?) {
|
||||
kotlin.requireNotNull(uids)
|
||||
for (uid in uids) {
|
||||
requireNotLocalUid(uid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireNotLocalUid(uid: String?) {
|
||||
kotlin.requireNotNull(uid)
|
||||
require(!uid.startsWith(K9.LOCAL_UID_PREFIX)) { "Local UID found: $uid" }
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.fsck.k9.controller;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.Timer;
|
||||
import java.util.TimerTask;
|
||||
|
||||
import com.fsck.k9.mail.DefaultBodyFactory;
|
||||
import org.apache.commons.io.output.CountingOutputStream;
|
||||
|
||||
|
||||
class ProgressBodyFactory extends DefaultBodyFactory {
|
||||
private final ProgressListener progressListener;
|
||||
|
||||
|
||||
ProgressBodyFactory(ProgressListener progressListener) {
|
||||
this.progressListener = progressListener;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void copyData(InputStream inputStream, OutputStream outputStream) throws IOException {
|
||||
Timer timer = new Timer();
|
||||
try (CountingOutputStream countingOutputStream = new CountingOutputStream(outputStream)) {
|
||||
timer.scheduleAtFixedRate(new TimerTask() {
|
||||
@Override
|
||||
public void run() {
|
||||
progressListener.updateProgress(countingOutputStream.getCount());
|
||||
}
|
||||
}, 0, 50);
|
||||
|
||||
super.copyData(inputStream, countingOutputStream);
|
||||
} finally {
|
||||
timer.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
interface ProgressListener {
|
||||
void updateProgress(int progress);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package com.fsck.k9.controller;
|
||||
|
||||
|
||||
import java.util.Comparator;
|
||||
|
||||
import com.fsck.k9.mail.Message;
|
||||
|
||||
|
||||
public class UidReverseComparator implements Comparator<Message> {
|
||||
@Override
|
||||
public int compare(Message messageLeft, Message messageRight) {
|
||||
Long uidLeft = getUidForMessage(messageLeft);
|
||||
Long uidRight = getUidForMessage(messageRight);
|
||||
|
||||
if (uidLeft == null && uidRight == null) {
|
||||
return 0;
|
||||
} else if (uidLeft == null) {
|
||||
return 1;
|
||||
} else if (uidRight == null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// reverse order
|
||||
return uidRight.compareTo(uidLeft);
|
||||
}
|
||||
|
||||
private Long getUidForMessage(Message message) {
|
||||
try {
|
||||
return Long.parseLong(message.getUid());
|
||||
} catch (NullPointerException | NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
package com.fsck.k9.controller.push
|
||||
|
||||
import app.k9mail.legacy.mailstore.FolderRepository
|
||||
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 kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
|
||||
internal class AccountPushController(
|
||||
private val backendManager: BackendManager,
|
||||
private val messagingController: MessagingController,
|
||||
private val folderRepository: FolderRepository,
|
||||
backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
private val account: LegacyAccount,
|
||||
) {
|
||||
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() {
|
||||
Log.v("AccountPushController(%s) - Push not supported. Disabling Push for account.", account.uuid)
|
||||
disablePush()
|
||||
}
|
||||
}
|
||||
|
||||
fun start() {
|
||||
Log.v("AccountPushController(%s).start()", account.uuid)
|
||||
startBackendPusher()
|
||||
startListeningForPushFolders()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
Log.v("AccountPushController(%s).stop()", account.uuid)
|
||||
stopListeningForPushFolders()
|
||||
stopBackendPusher()
|
||||
}
|
||||
|
||||
fun reconnect() {
|
||||
Log.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>) {
|
||||
Log.v("AccountPushController(%s).updatePushFolders(): %s", account.uuid, folderServerIds)
|
||||
|
||||
backendPusher?.updateFolders(folderServerIds)
|
||||
}
|
||||
|
||||
private fun syncFolders(folderServerId: String) {
|
||||
messagingController.synchronizeMailboxBlocking(account, folderServerId)
|
||||
}
|
||||
|
||||
private fun disablePush() {
|
||||
folderRepository.setPushDisabled(account)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.fsck.k9.controller.push
|
||||
|
||||
import app.k9mail.legacy.mailstore.FolderRepository
|
||||
import com.fsck.k9.backend.BackendManager
|
||||
import com.fsck.k9.controller.MessagingController
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
|
||||
internal class AccountPushControllerFactory(
|
||||
private val backendManager: BackendManager,
|
||||
private val messagingController: MessagingController,
|
||||
private val folderRepository: FolderRepository,
|
||||
) {
|
||||
fun create(account: LegacyAccount): AccountPushController {
|
||||
return AccountPushController(
|
||||
backendManager,
|
||||
messagingController,
|
||||
folderRepository,
|
||||
account = account,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package com.fsck.k9.controller.push
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
|
||||
/**
|
||||
* Checks whether the app can schedule exact alarms.
|
||||
*/
|
||||
internal interface AlarmPermissionManager {
|
||||
/**
|
||||
* Checks whether the app can schedule exact alarms.
|
||||
*
|
||||
* If this method returns `false`, the app has to request the permission to schedule exact alarms. See
|
||||
* [Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM].
|
||||
*/
|
||||
fun canScheduleExactAlarms(): Boolean
|
||||
|
||||
/**
|
||||
* Register a listener to be notified when the app was granted the permission to schedule exact alarms.
|
||||
*/
|
||||
fun registerListener(listener: AlarmPermissionListener)
|
||||
|
||||
/**
|
||||
* Unregister the listener registered via [registerListener].
|
||||
*/
|
||||
fun unregisterListener()
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create an Android API-specific instance of [AlarmPermissionManager].
|
||||
*/
|
||||
internal fun AlarmPermissionManager(context: Context, alarmManager: AlarmManager): AlarmPermissionManager {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
AlarmPermissionManagerApi31(context, alarmManager)
|
||||
} else {
|
||||
AlarmPermissionManagerApi21()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener that can be notified when the app was granted the permission to schedule exact alarms.
|
||||
*
|
||||
* Note: Currently Android stops (and potentially restarts) the app when the permission is revoked. So there's no
|
||||
* callback mechanism for the permission revocation case.
|
||||
*/
|
||||
internal fun interface AlarmPermissionListener {
|
||||
fun onAlarmPermissionGranted()
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.fsck.k9.controller.push
|
||||
|
||||
/**
|
||||
* On Android versions prior to 12 there's no permission to limit an app's ability to schedule exact alarms.
|
||||
*/
|
||||
internal class AlarmPermissionManagerApi21 : AlarmPermissionManager {
|
||||
override fun canScheduleExactAlarms(): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun registerListener(listener: AlarmPermissionListener) = Unit
|
||||
|
||||
override fun unregisterListener() = Unit
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
package com.fsck.k9.controller.push
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.AlarmManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
|
||||
/**
|
||||
* Starting with Android 12 we have to check whether the app can schedule exact alarms.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.S)
|
||||
internal class AlarmPermissionManagerApi31(
|
||||
private val context: Context,
|
||||
private val alarmManager: AlarmManager,
|
||||
) : AlarmPermissionManager {
|
||||
private var isRegistered = false
|
||||
private var listener: AlarmPermissionListener? = null
|
||||
|
||||
private val intentFilter = IntentFilter().apply {
|
||||
addAction(AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED)
|
||||
}
|
||||
|
||||
private val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
val listener = synchronized(this@AlarmPermissionManagerApi31) { listener }
|
||||
listener?.onAlarmPermissionGranted()
|
||||
}
|
||||
}
|
||||
|
||||
override fun canScheduleExactAlarms(): Boolean {
|
||||
return AlarmManagerCompat.canScheduleExactAlarms(alarmManager)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun registerListener(listener: AlarmPermissionListener) {
|
||||
if (!isRegistered) {
|
||||
Log.v("Registering alarm permission listener")
|
||||
isRegistered = true
|
||||
this.listener = listener
|
||||
ContextCompat.registerReceiver(context, receiver, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun unregisterListener() {
|
||||
if (isRegistered) {
|
||||
Log.v("Unregistering alarm permission listener")
|
||||
isRegistered = false
|
||||
listener = null
|
||||
context.unregisterReceiver(receiver)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
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 androidx.core.content.ContextCompat
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import net.thunderbird.core.preference.BackgroundOps
|
||||
import net.thunderbird.core.preference.GeneralSettingsManager
|
||||
|
||||
/**
|
||||
* Listen for changes to the system's auto sync setting.
|
||||
*/
|
||||
internal class AutoSyncManager(
|
||||
private val context: Context,
|
||||
private val generalSettingsManager: GeneralSettingsManager,
|
||||
) {
|
||||
val isAutoSyncDisabled: Boolean
|
||||
get() = respectSystemAutoSync && !ContentResolver.getMasterSyncAutomatically()
|
||||
|
||||
val respectSystemAutoSync: Boolean
|
||||
get() = generalSettingsManager.getConfig().network.backgroundOps == BackgroundOps.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) {
|
||||
Log.v("Registering auto sync listener")
|
||||
isRegistered = true
|
||||
this.listener = listener
|
||||
ContextCompat.registerReceiver(context, receiver, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun unregisterListener() {
|
||||
if (isRegistered) {
|
||||
Log.v("Unregistering auto sync listener")
|
||||
isRegistered = false
|
||||
context.unregisterReceiver(receiver)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun interface AutoSyncListener {
|
||||
fun onAutoSyncChanged()
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
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 net.thunderbird.core.logging.legacy.Log
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
class BootCompleteReceiver : BroadcastReceiver(), KoinComponent {
|
||||
private val pushController: PushController by inject()
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
Log.v("BootCompleteReceiver.onReceive() - %s", intent?.action)
|
||||
|
||||
pushController.init()
|
||||
}
|
||||
}
|
||||
|
||||
class BootCompleteManager(context: Context) {
|
||||
private val packageManager = context.packageManager
|
||||
private val componentName = ComponentName(context, BootCompleteReceiver::class.java)
|
||||
|
||||
fun enableReceiver() {
|
||||
Log.v("Enable BootCompleteReceiver")
|
||||
try {
|
||||
packageManager.setComponentEnabledSetting(componentName, COMPONENT_ENABLED_STATE_ENABLED, DONT_KILL_APP)
|
||||
} catch (e: Exception) {
|
||||
Log.e(e, "Error enabling BootCompleteReceiver")
|
||||
}
|
||||
}
|
||||
|
||||
fun disableReceiver() {
|
||||
Log.v("Disable BootCompleteReceiver")
|
||||
try {
|
||||
packageManager.setComponentEnabledSetting(componentName, COMPONENT_ENABLED_STATE_DISABLED, DONT_KILL_APP)
|
||||
} catch (e: Exception) {
|
||||
Log.e(e, "Error disabling BootCompleteReceiver")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
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(), generalSettingsManager = get()) }
|
||||
single {
|
||||
AccountPushControllerFactory(
|
||||
backendManager = get(),
|
||||
messagingController = get(),
|
||||
folderRepository = get(),
|
||||
)
|
||||
}
|
||||
single {
|
||||
PushController(
|
||||
accountManager = get(),
|
||||
generalSettingsManager = get(),
|
||||
backendManager = get(),
|
||||
pushServiceManager = get(),
|
||||
bootCompleteManager = get(),
|
||||
autoSyncManager = get(),
|
||||
alarmPermissionManager = get(),
|
||||
pushNotificationManager = get(),
|
||||
connectivityManager = get(),
|
||||
accountPushControllerFactory = get(),
|
||||
folderRepository = get(),
|
||||
)
|
||||
}
|
||||
|
||||
single<AlarmPermissionManager> { AlarmPermissionManager(context = get(), alarmManager = get()) }
|
||||
}
|
||||
|
|
@ -0,0 +1,337 @@
|
|||
package com.fsck.k9.controller.push
|
||||
|
||||
import app.k9mail.legacy.mailstore.FolderRepository
|
||||
import com.fsck.k9.backend.BackendManager
|
||||
import com.fsck.k9.helper.mapToSet
|
||||
import com.fsck.k9.notification.PushNotificationManager
|
||||
import com.fsck.k9.notification.PushNotificationState
|
||||
import com.fsck.k9.notification.PushNotificationState.ALARM_PERMISSION_MISSING
|
||||
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 java.util.concurrent.Executors
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
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 net.thunderbird.core.android.account.AccountManager
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
import net.thunderbird.core.android.network.ConnectivityChangeListener
|
||||
import net.thunderbird.core.android.network.ConnectivityManager
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import net.thunderbird.core.preference.BackgroundOps
|
||||
import net.thunderbird.core.preference.BackgroundSync
|
||||
import net.thunderbird.core.preference.GeneralSettingsManager
|
||||
|
||||
/**
|
||||
* Starts and stops [AccountPushController]s as necessary. Manages the Push foreground service.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
class PushController internal constructor(
|
||||
private val accountManager: AccountManager,
|
||||
private val generalSettingsManager: GeneralSettingsManager,
|
||||
private val backendManager: BackendManager,
|
||||
private val pushServiceManager: PushServiceManager,
|
||||
private val bootCompleteManager: BootCompleteManager,
|
||||
private val autoSyncManager: AutoSyncManager,
|
||||
private val alarmPermissionManager: AlarmPermissionManager,
|
||||
private val pushNotificationManager: PushNotificationManager,
|
||||
private val connectivityManager: ConnectivityManager,
|
||||
private val accountPushControllerFactory: AccountPushControllerFactory,
|
||||
private val folderRepository: FolderRepository,
|
||||
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 pushEnabledCollectorJobs = mutableMapOf<String, Job>()
|
||||
|
||||
private val autoSyncListener = AutoSyncListener(::onAutoSyncChanged)
|
||||
private val connectivityChangeListener = object : ConnectivityChangeListener {
|
||||
override fun onConnectivityChanged() = this@PushController.onConnectivityChanged()
|
||||
override fun onConnectivityLost() = this@PushController.onConnectivityLost()
|
||||
}
|
||||
private val alarmPermissionListener = AlarmPermissionListener(::onAlarmPermissionGranted)
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
Log.v("PushController.disablePush()")
|
||||
|
||||
coroutineScope.launch(coroutineDispatcher) {
|
||||
for (account in accountManager.getAccounts()) {
|
||||
folderRepository.setPushDisabled(account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initInBackground() {
|
||||
Log.v("PushController.initInBackground()")
|
||||
|
||||
accountManager.addOnAccountsChangeListener(::onAccountsChanged)
|
||||
listenForBackgroundSyncChanges()
|
||||
backendManager.addListener(::onBackendChanged)
|
||||
|
||||
updatePushers()
|
||||
}
|
||||
|
||||
private fun listenForBackgroundSyncChanges() {
|
||||
generalSettingsManager.getConfigFlow()
|
||||
.map { it.network.backgroundOps }
|
||||
.distinctUntilChanged()
|
||||
.onEach {
|
||||
launchUpdatePushers()
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
private fun onAccountsChanged() {
|
||||
launchUpdatePushers()
|
||||
}
|
||||
|
||||
private fun onAutoSyncChanged() {
|
||||
launchUpdatePushers()
|
||||
}
|
||||
|
||||
private fun onAlarmPermissionGranted() {
|
||||
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: LegacyAccount) {
|
||||
coroutineScope.launch(coroutineDispatcher) {
|
||||
val accountPushController = synchronized(lock) {
|
||||
pushers.remove(account.uuid)
|
||||
}
|
||||
|
||||
accountPushController?.stop()
|
||||
updatePushers()
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchUpdatePushers() {
|
||||
coroutineScope.launch(coroutineDispatcher) {
|
||||
updatePushers()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
private fun updatePushers() {
|
||||
Log.v("PushController.updatePushers()")
|
||||
|
||||
val generalSettings = generalSettingsManager.getSettings()
|
||||
|
||||
val alarmPermissionMissing = !alarmPermissionManager.canScheduleExactAlarms()
|
||||
val backgroundSyncDisabledViaSystem = autoSyncManager.isAutoSyncDisabled
|
||||
val backgroundSyncDisabledInApp =
|
||||
generalSettings.network.backgroundOps.toBackgroundSync() == BackgroundSync.NEVER
|
||||
val networkNotAvailable = !connectivityManager.isNetworkAvailable()
|
||||
val realPushAccounts = getPushAccounts()
|
||||
|
||||
val shouldDisablePushAccounts = backgroundSyncDisabledViaSystem ||
|
||||
backgroundSyncDisabledInApp ||
|
||||
networkNotAvailable ||
|
||||
alarmPermissionMissing
|
||||
|
||||
val pushAccounts = if (shouldDisablePushAccounts) {
|
||||
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()) {
|
||||
Log.v("..Stopping PushController for accounts: %s", stopPushAccountUuids)
|
||||
for (accountUuid in stopPushAccountUuids) {
|
||||
val accountPushController = pushers.remove(accountUuid)
|
||||
accountPushController?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
if (startPushAccountUuids.isNotEmpty()) {
|
||||
Log.v("..Starting PushController for accounts: %s", startPushAccountUuids)
|
||||
for (accountUuid in startPushAccountUuids) {
|
||||
val account = accountManager.getAccount(accountUuid) ?: error("Account not found: $accountUuid")
|
||||
pushers[accountUuid] = accountPushControllerFactory.create(account).also { accountPushController ->
|
||||
accountPushController.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.v("..Running PushControllers: %s", pushers.keys)
|
||||
|
||||
pushers.isNotEmpty()
|
||||
}
|
||||
|
||||
updatePushEnabledListeners(getPushCapableAccounts())
|
||||
|
||||
when {
|
||||
realPushAccounts.isEmpty() -> {
|
||||
stopServices()
|
||||
}
|
||||
|
||||
backgroundSyncDisabledViaSystem -> {
|
||||
setPushNotificationState(WAIT_BACKGROUND_SYNC)
|
||||
startServices()
|
||||
}
|
||||
|
||||
networkNotAvailable -> {
|
||||
setPushNotificationState(WAIT_NETWORK)
|
||||
startServices()
|
||||
}
|
||||
|
||||
alarmPermissionMissing -> {
|
||||
setPushNotificationState(ALARM_PERMISSION_MISSING)
|
||||
startServices()
|
||||
}
|
||||
|
||||
arePushersActive -> {
|
||||
setPushNotificationState(LISTENING)
|
||||
startServices()
|
||||
}
|
||||
|
||||
else -> {
|
||||
stopServices()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPushCapableAccounts(): Set<LegacyAccount> {
|
||||
return accountManager.getAccounts()
|
||||
.asSequence()
|
||||
.filter { account -> backendManager.getBackend(account).isPushCapable }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
private fun getPushAccounts(): Set<LegacyAccount> {
|
||||
return getPushCapableAccounts()
|
||||
.asSequence()
|
||||
.filter { account -> folderRepository.hasPushEnabledFolder(account) }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
private fun setPushNotificationState(notificationState: PushNotificationState) {
|
||||
pushNotificationManager.notificationState = notificationState
|
||||
}
|
||||
|
||||
private fun startServices() {
|
||||
pushServiceManager.start()
|
||||
bootCompleteManager.enableReceiver()
|
||||
registerAutoSyncListener()
|
||||
registerConnectivityChangeListener()
|
||||
registerAlarmPermissionListener()
|
||||
connectivityManager.start()
|
||||
}
|
||||
|
||||
private fun stopServices() {
|
||||
pushServiceManager.stop()
|
||||
bootCompleteManager.disableReceiver()
|
||||
autoSyncManager.unregisterListener()
|
||||
unregisterConnectivityChangeListener()
|
||||
alarmPermissionManager.unregisterListener()
|
||||
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)
|
||||
}
|
||||
|
||||
private fun registerAlarmPermissionListener() {
|
||||
if (!alarmPermissionManager.canScheduleExactAlarms()) {
|
||||
alarmPermissionManager.registerListener(alarmPermissionListener)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePushEnabledListeners(accounts: Set<LegacyAccount>) {
|
||||
synchronized(lock) {
|
||||
// Stop listening to push enabled changes in accounts we no longer monitor
|
||||
val accountUuids = accounts.mapToSet { it.uuid }
|
||||
val iterator = pushEnabledCollectorJobs.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val (accountUuid, collectorJob) = iterator.next()
|
||||
if (accountUuid !in accountUuids) {
|
||||
Log.v("..Stopping to listen for push enabled changes in account: %s", accountUuid)
|
||||
iterator.remove()
|
||||
collectorJob.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Start "push enabled" state collector jobs for new accounts to monitor
|
||||
val newAccounts = accounts.filterNot { account -> pushEnabledCollectorJobs.containsKey(account.uuid) }
|
||||
for (account in newAccounts) {
|
||||
pushEnabledCollectorJobs[account.uuid] = coroutineScope.launch(coroutineDispatcher) {
|
||||
Log.v("..Starting to listen for push enabled changes in account: %s", account.uuid)
|
||||
folderRepository.hasPushEnabledFolderFlow(account)
|
||||
.collect {
|
||||
updatePushers()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun BackgroundOps.toBackgroundSync(): BackgroundSync {
|
||||
return when (this) {
|
||||
BackgroundOps.ALWAYS -> BackgroundSync.ALWAYS
|
||||
BackgroundOps.NEVER -> BackgroundSync.NEVER
|
||||
BackgroundOps.WHEN_CHECKED_AUTO_SYNC -> BackgroundSync.FOLLOW_SYSTEM_AUTO_SYNC
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
package com.fsck.k9.controller.push
|
||||
|
||||
import android.app.ForegroundServiceStartNotAllowedException
|
||||
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 net.thunderbird.core.logging.legacy.Log
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
/**
|
||||
* Foreground service that is used to keep the app alive while listening for new emails (Push).
|
||||
*/
|
||||
class PushService : Service() {
|
||||
private val pushServiceManager: PushServiceManager by inject()
|
||||
private val pushNotificationManager: PushNotificationManager by inject()
|
||||
private val pushController: PushController by inject()
|
||||
|
||||
override fun onCreate() {
|
||||
Log.v("PushService.onCreate()")
|
||||
super.onCreate()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.v("PushService.onStartCommand(%s)", intent)
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
|
||||
val isAutomaticRestart = intent == null
|
||||
if (isAutomaticRestart) {
|
||||
maybeStartForeground()
|
||||
initializePushController()
|
||||
} else {
|
||||
startForeground()
|
||||
}
|
||||
|
||||
notifyServiceStarted()
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.v("PushService.onDestroy()")
|
||||
pushNotificationManager.setForegroundServiceStopped()
|
||||
notifyServiceStopped()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun maybeStartForeground() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||
startForeground()
|
||||
} else {
|
||||
try {
|
||||
startForeground()
|
||||
} catch (e: ForegroundServiceStartNotAllowedException) {
|
||||
Log.e(e, "Ignoring ForegroundServiceStartNotAllowedException during automatic restart.")
|
||||
|
||||
// This works around what seems to be a bug in at least Android 14.
|
||||
// See https://github.com/thunderbird/thunderbird-android/issues/7416 for more details.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startForeground() {
|
||||
val notificationId = pushNotificationManager.notificationId
|
||||
val notification = pushNotificationManager.createForegroundNotification()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(notificationId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(notificationId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(notificationId, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyServiceStarted() {
|
||||
// If our process was low-memory killed and now this service is being restarted by the system,
|
||||
// PushServiceManager doesn't necessarily know about this service's state. So we're updating it now.
|
||||
pushServiceManager.setServiceStarted()
|
||||
}
|
||||
|
||||
private fun notifyServiceStopped() {
|
||||
// Usually this service is only stopped via PushServiceManager. But we still notify PushServiceManager here in
|
||||
// case the system decided to stop the service (without killing the process).
|
||||
pushServiceManager.setServiceStopped()
|
||||
}
|
||||
|
||||
private fun initializePushController() {
|
||||
// When the app is killed by the system and later recreated to start this service nobody else is initializing
|
||||
// PushController. So we'll have to do it here.
|
||||
pushController.init()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
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 net.thunderbird.core.logging.legacy.Log
|
||||
|
||||
/**
|
||||
* Manages starting and stopping [PushService].
|
||||
*/
|
||||
internal class PushServiceManager(private val context: Context) {
|
||||
private var isServiceStarted = AtomicBoolean(false)
|
||||
|
||||
fun start() {
|
||||
Log.v("PushServiceManager.start()")
|
||||
if (isServiceStarted.compareAndSet(false, true)) {
|
||||
startService()
|
||||
} else {
|
||||
Log.v("..PushService already running")
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
Log.v("PushServiceManager.stop()")
|
||||
if (isServiceStarted.compareAndSet(true, false)) {
|
||||
stopService()
|
||||
} else {
|
||||
Log.v("..PushService is not running")
|
||||
}
|
||||
}
|
||||
|
||||
fun setServiceStarted() {
|
||||
Log.v("PushServiceManager.setServiceStarted()")
|
||||
isServiceStarted.set(true)
|
||||
}
|
||||
|
||||
fun setServiceStopped() {
|
||||
Log.v("PushServiceManager.setServiceStopped()")
|
||||
isServiceStarted.set(false)
|
||||
}
|
||||
|
||||
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) {
|
||||
Log.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) {
|
||||
Log.w(e, "Exception while trying to stop PushService")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.fsck.k9.crypto
|
||||
|
||||
import android.content.ContentValues
|
||||
import app.k9mail.legacy.message.extractors.PreviewResult
|
||||
import com.fsck.k9.mail.Message
|
||||
|
||||
interface EncryptionExtractor {
|
||||
fun extractEncryption(message: Message): EncryptionResult?
|
||||
}
|
||||
|
||||
data class EncryptionResult(
|
||||
val encryptionType: String,
|
||||
val attachmentCount: Int,
|
||||
val previewResult: PreviewResult = PreviewResult.encrypted(),
|
||||
val textForSearchIndex: String? = null,
|
||||
val extraContentValues: ContentValues? = null,
|
||||
)
|
||||
11
legacy/core/src/main/java/com/fsck/k9/crypto/KoinModule.kt
Normal file
11
legacy/core/src/main/java/com/fsck/k9/crypto/KoinModule.kt
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package com.fsck.k9.crypto
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import org.koin.dsl.module
|
||||
import org.openintents.openpgp.OpenPgpApiManager
|
||||
|
||||
val openPgpModule = module {
|
||||
factory { (lifecycleOwner: LifecycleOwner) ->
|
||||
OpenPgpApiManager(get(), lifecycleOwner)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
package com.fsck.k9.crypto;
|
||||
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Stack;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
|
||||
import com.fsck.k9.helper.StringHelper;
|
||||
import com.fsck.k9.mail.Body;
|
||||
import com.fsck.k9.mail.BodyPart;
|
||||
import net.thunderbird.core.common.exception.MessagingException;
|
||||
import com.fsck.k9.mail.Multipart;
|
||||
import com.fsck.k9.mail.Part;
|
||||
import com.fsck.k9.mail.internet.MessageExtractor;
|
||||
import com.fsck.k9.mail.internet.MimeBodyPart;
|
||||
import com.fsck.k9.mail.internet.MimeMultipart;
|
||||
import com.fsck.k9.mail.internet.MimeUtility;
|
||||
import com.fsck.k9.mailstore.CryptoResultAnnotation;
|
||||
import com.fsck.k9.mailstore.MessageCryptoAnnotations;
|
||||
|
||||
import static com.fsck.k9.mail.internet.MimeUtility.isSameMimeType;
|
||||
|
||||
|
||||
public class MessageCryptoStructureDetector {
|
||||
private static final String MULTIPART_ENCRYPTED = "multipart/encrypted";
|
||||
private static final String MULTIPART_SIGNED = "multipart/signed";
|
||||
private static final String PROTOCOL_PARAMETER = "protocol";
|
||||
private static final String APPLICATION_PGP_ENCRYPTED = "application/pgp-encrypted";
|
||||
private static final String APPLICATION_PGP_SIGNATURE = "application/pgp-signature";
|
||||
private static final String TEXT_PLAIN = "text/plain";
|
||||
// APPLICATION/PGP is a special case which occurs from mutt. see http://www.mutt.org/doc/PGP-Notes.txt
|
||||
private static final String APPLICATION_PGP = "application/pgp";
|
||||
|
||||
private static final String PGP_INLINE_START_MARKER = "-----BEGIN PGP MESSAGE-----";
|
||||
private static final String PGP_INLINE_SIGNED_START_MARKER = "-----BEGIN PGP SIGNED MESSAGE-----";
|
||||
private static final int TEXT_LENGTH_FOR_INLINE_CHECK = 36;
|
||||
|
||||
|
||||
public static Part findPrimaryEncryptedOrSignedPart(Part part, List<Part> outputExtraParts) {
|
||||
if (isPartEncryptedOrSigned(part)) {
|
||||
return part;
|
||||
}
|
||||
|
||||
Part foundPart;
|
||||
|
||||
foundPart = findPrimaryPartInAlternative(part);
|
||||
if (foundPart != null) {
|
||||
return foundPart;
|
||||
}
|
||||
|
||||
foundPart = findPrimaryPartInMixed(part, outputExtraParts);
|
||||
if (foundPart != null) {
|
||||
return foundPart;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static Part findPrimaryPartInMixed(Part part, List<Part> outputExtraParts) {
|
||||
Body body = part.getBody();
|
||||
|
||||
boolean isMultipartMixed = part.isMimeType("multipart/mixed") && body instanceof Multipart;
|
||||
if (!isMultipartMixed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Multipart multipart = (Multipart) body;
|
||||
if (multipart.getCount() == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
BodyPart firstBodyPart = multipart.getBodyPart(0);
|
||||
|
||||
Part foundPart;
|
||||
if (isPartEncryptedOrSigned(firstBodyPart)) {
|
||||
foundPart = firstBodyPart;
|
||||
} else {
|
||||
foundPart = findPrimaryPartInAlternative(firstBodyPart);
|
||||
}
|
||||
|
||||
if (foundPart != null && outputExtraParts != null) {
|
||||
for (int i = 1; i < multipart.getCount(); i++) {
|
||||
outputExtraParts.add(multipart.getBodyPart(i));
|
||||
}
|
||||
}
|
||||
|
||||
return foundPart;
|
||||
}
|
||||
|
||||
private static Part findPrimaryPartInAlternative(Part part) {
|
||||
Body body = part.getBody();
|
||||
if (part.isMimeType("multipart/alternative") && body instanceof Multipart) {
|
||||
Multipart multipart = (Multipart) body;
|
||||
if (multipart.getCount() == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
BodyPart firstBodyPart = multipart.getBodyPart(0);
|
||||
if (isPartPgpInlineEncryptedOrSigned(firstBodyPart)) {
|
||||
return firstBodyPart;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static List<Part> findMultipartEncryptedParts(Part startPart) {
|
||||
List<Part> encryptedParts = new ArrayList<>();
|
||||
Stack<Part> partsToCheck = new Stack<>();
|
||||
partsToCheck.push(startPart);
|
||||
|
||||
while (!partsToCheck.isEmpty()) {
|
||||
Part part = partsToCheck.pop();
|
||||
Body body = part.getBody();
|
||||
|
||||
if (isPartMultipartEncrypted(part)) {
|
||||
encryptedParts.add(part);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (body instanceof Multipart) {
|
||||
Multipart multipart = (Multipart) body;
|
||||
for (int i = multipart.getCount() - 1; i >= 0; i--) {
|
||||
BodyPart bodyPart = multipart.getBodyPart(i);
|
||||
partsToCheck.push(bodyPart);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return encryptedParts;
|
||||
}
|
||||
|
||||
public static List<Part> findMultipartSignedParts(Part startPart, MessageCryptoAnnotations messageCryptoAnnotations) {
|
||||
List<Part> signedParts = new ArrayList<>();
|
||||
Stack<Part> partsToCheck = new Stack<>();
|
||||
partsToCheck.push(startPart);
|
||||
|
||||
while (!partsToCheck.isEmpty()) {
|
||||
Part part = partsToCheck.pop();
|
||||
if (messageCryptoAnnotations.has(part)) {
|
||||
CryptoResultAnnotation resultAnnotation = messageCryptoAnnotations.get(part);
|
||||
MimeBodyPart replacementData = resultAnnotation.getReplacementData();
|
||||
if (replacementData != null) {
|
||||
part = replacementData;
|
||||
}
|
||||
}
|
||||
Body body = part.getBody();
|
||||
|
||||
if (isPartMultipartSigned(part)) {
|
||||
signedParts.add(part);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (body instanceof Multipart) {
|
||||
Multipart multipart = (Multipart) body;
|
||||
for (int i = multipart.getCount() - 1; i >= 0; i--) {
|
||||
BodyPart bodyPart = multipart.getBodyPart(i);
|
||||
partsToCheck.push(bodyPart);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return signedParts;
|
||||
}
|
||||
|
||||
public static List<Part> findPgpInlineParts(Part startPart) {
|
||||
List<Part> inlineParts = new ArrayList<>();
|
||||
Stack<Part> partsToCheck = new Stack<>();
|
||||
partsToCheck.push(startPart);
|
||||
|
||||
while (!partsToCheck.isEmpty()) {
|
||||
Part part = partsToCheck.pop();
|
||||
Body body = part.getBody();
|
||||
|
||||
if (isPartPgpInlineEncryptedOrSigned(part)) {
|
||||
inlineParts.add(part);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (body instanceof Multipart) {
|
||||
Multipart multipart = (Multipart) body;
|
||||
for (int i = multipart.getCount() - 1; i >= 0; i--) {
|
||||
BodyPart bodyPart = multipart.getBodyPart(i);
|
||||
partsToCheck.push(bodyPart);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return inlineParts;
|
||||
}
|
||||
|
||||
public static byte[] getSignatureData(Part part) throws IOException, MessagingException {
|
||||
if (isPartMultipartSigned(part)) {
|
||||
Body body = part.getBody();
|
||||
if (body instanceof Multipart) {
|
||||
Multipart multi = (Multipart) body;
|
||||
BodyPart signatureBody = multi.getBodyPart(1);
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
signatureBody.getBody().writeTo(bos);
|
||||
return bos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isPartEncryptedOrSigned(Part part) {
|
||||
return isPartMultipartEncrypted(part) || isPartMultipartSigned(part) || isPartPgpInlineEncryptedOrSigned(part);
|
||||
}
|
||||
|
||||
private static boolean isPartMultipartSigned(Part part) {
|
||||
if (!isSameMimeType(part.getMimeType(), MULTIPART_SIGNED)) {
|
||||
return false;
|
||||
}
|
||||
if (! (part.getBody() instanceof MimeMultipart)) {
|
||||
return false;
|
||||
}
|
||||
MimeMultipart mimeMultipart = (MimeMultipart) part.getBody();
|
||||
if (mimeMultipart.getCount() != 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String protocolParameter = MimeUtility.getHeaderParameter(part.getContentType(), PROTOCOL_PARAMETER);
|
||||
|
||||
// for partially downloaded messages the protocol parameter isn't yet available, so we'll just assume it's ok
|
||||
boolean dataUnavailable = protocolParameter == null && mimeMultipart.getBodyPart(0).getBody() == null;
|
||||
boolean protocolMatches = isSameMimeType(protocolParameter, mimeMultipart.getBodyPart(1).getMimeType());
|
||||
return dataUnavailable || protocolMatches;
|
||||
}
|
||||
|
||||
public static boolean isPartMultipartEncrypted(Part part) {
|
||||
if (!isSameMimeType(part.getMimeType(), MULTIPART_ENCRYPTED)) {
|
||||
return false;
|
||||
}
|
||||
if (! (part.getBody() instanceof MimeMultipart)) {
|
||||
return false;
|
||||
}
|
||||
MimeMultipart mimeMultipart = (MimeMultipart) part.getBody();
|
||||
if (mimeMultipart.getCount() != 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String protocolParameter = MimeUtility.getHeaderParameter(part.getContentType(), PROTOCOL_PARAMETER);
|
||||
|
||||
// for partially downloaded messages the protocol parameter isn't yet available, so we'll just assume it's ok
|
||||
boolean dataUnavailable = protocolParameter == null && mimeMultipart.getBodyPart(1).getBody() == null;
|
||||
boolean protocolMatches = isSameMimeType(protocolParameter, mimeMultipart.getBodyPart(0).getMimeType());
|
||||
return dataUnavailable || protocolMatches;
|
||||
}
|
||||
|
||||
public static boolean isMultipartEncryptedOpenPgpProtocol(Part part) {
|
||||
if (!isSameMimeType(part.getMimeType(), MULTIPART_ENCRYPTED)) {
|
||||
throw new IllegalArgumentException("Part is not multipart/encrypted!");
|
||||
}
|
||||
|
||||
String protocolParameter = MimeUtility.getHeaderParameter(part.getContentType(), PROTOCOL_PARAMETER);
|
||||
return APPLICATION_PGP_ENCRYPTED.equalsIgnoreCase(protocolParameter);
|
||||
}
|
||||
|
||||
public static boolean isMultipartSignedOpenPgpProtocol(Part part) {
|
||||
if (!isSameMimeType(part.getMimeType(), MULTIPART_SIGNED)) {
|
||||
throw new IllegalArgumentException("Part is not multipart/signed!");
|
||||
}
|
||||
|
||||
String protocolParameter = MimeUtility.getHeaderParameter(part.getContentType(), PROTOCOL_PARAMETER);
|
||||
return APPLICATION_PGP_SIGNATURE.equalsIgnoreCase(protocolParameter);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static boolean isPartPgpInlineEncryptedOrSigned(Part part) {
|
||||
if (!part.isMimeType(TEXT_PLAIN) && !part.isMimeType(APPLICATION_PGP)) {
|
||||
return false;
|
||||
}
|
||||
String text = MessageExtractor.getTextFromPart(part, TEXT_LENGTH_FOR_INLINE_CHECK);
|
||||
if (StringHelper.isNullOrEmpty(text)) {
|
||||
return false;
|
||||
}
|
||||
text = text.trim();
|
||||
return text.startsWith(PGP_INLINE_START_MARKER) || text.startsWith(PGP_INLINE_SIGNED_START_MARKER);
|
||||
}
|
||||
|
||||
public static boolean isPartPgpInlineEncrypted(@Nullable Part part) {
|
||||
if (part == null) {
|
||||
return false;
|
||||
}
|
||||
if (!part.isMimeType(TEXT_PLAIN) && !part.isMimeType(APPLICATION_PGP)) {
|
||||
return false;
|
||||
}
|
||||
String text = MessageExtractor.getTextFromPart(part, TEXT_LENGTH_FOR_INLINE_CHECK);
|
||||
if (StringHelper.isNullOrEmpty(text)) {
|
||||
return false;
|
||||
}
|
||||
text = text.trim();
|
||||
return text.startsWith(PGP_INLINE_START_MARKER);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package com.fsck.k9.crypto;
|
||||
|
||||
import com.fsck.k9.helper.StringHelper;
|
||||
import net.thunderbird.core.android.account.Identity;
|
||||
|
||||
|
||||
public class OpenPgpApiHelper {
|
||||
|
||||
/**
|
||||
* Create an "account name" from the supplied identity for use with the OpenPgp API's
|
||||
* <code>EXTRA_ACCOUNT_NAME</code>.
|
||||
*
|
||||
* @return A string with the following format:
|
||||
* <code>display name <user@example.com></code>
|
||||
*/
|
||||
public static String buildUserId(Identity identity) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
String name = identity.getName();
|
||||
if (!StringHelper.isNullOrEmpty(name)) {
|
||||
sb.append(name).append(" ");
|
||||
}
|
||||
sb.append("<").append(identity.getEmail()).append(">");
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.fsck.k9.helper
|
||||
|
||||
import android.content.Context
|
||||
import com.fsck.k9.mail.ssl.KeyStoreDirectoryProvider
|
||||
import java.io.File
|
||||
|
||||
internal class AndroidKeyStoreDirectoryProvider(private val context: Context) : KeyStoreDirectoryProvider {
|
||||
override fun getDirectory(): File {
|
||||
return context.getDir("KeyStore", Context.MODE_PRIVATE)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.fsck.k9.helper
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* Access the system clipboard
|
||||
*/
|
||||
class ClipboardManager(private val context: Context) {
|
||||
|
||||
/**
|
||||
* Copy a text string to the system clipboard
|
||||
*
|
||||
* @param label User-visible label for the content.
|
||||
* @param text The actual text to be copied to the clipboard.
|
||||
*/
|
||||
fun setText(label: String, text: String) {
|
||||
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||
val clip = ClipData.newPlainText(label, text)
|
||||
clipboardManager.setPrimaryClip(clip)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.fsck.k9.helper
|
||||
|
||||
/**
|
||||
* Returns a [Set] containing the results of applying the given [transform] function to each element in the original
|
||||
* collection.
|
||||
*
|
||||
* If you know the size of the output or can make an educated guess, specify [expectedSize] as an optimization.
|
||||
* The initial capacity of the `Set` will be derived from this value.
|
||||
*/
|
||||
inline fun <T, R> Iterable<T>.mapToSet(expectedSize: Int? = null, transform: (T) -> R): Set<R> {
|
||||
return if (expectedSize != null) {
|
||||
mapTo(LinkedHashSet(setCapacity(expectedSize)), transform)
|
||||
} else {
|
||||
mapTo(mutableSetOf(), transform)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a [Set] containing the results of applying the given [transform] function to each element in the original
|
||||
* collection.
|
||||
*
|
||||
* The size of the output is expected to be equal to the size of the input. If that's not the case, please use
|
||||
* [mapToSet] instead.
|
||||
*/
|
||||
inline fun <T, R> Collection<T>.mapCollectionToSet(transform: (T) -> R): Set<R> {
|
||||
return mapToSet(expectedSize = size, transform)
|
||||
}
|
||||
|
||||
// A copy of Kotlin's internal mapCapacity() for the JVM
|
||||
fun setCapacity(expectedSize: Int): Int = when {
|
||||
// We are not coercing the value to a valid one and not throwing an exception. It is up to the caller to
|
||||
// properly handle negative values.
|
||||
expectedSize < 0 -> expectedSize
|
||||
expectedSize < 3 -> expectedSize + 1
|
||||
expectedSize < INT_MAX_POWER_OF_TWO -> ((expectedSize / 0.75F) + 1.0F).toInt()
|
||||
// any large value
|
||||
else -> Int.MAX_VALUE
|
||||
}
|
||||
|
||||
private const val INT_MAX_POWER_OF_TWO: Int = 1 shl (Int.SIZE_BITS - 2)
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.fsck.k9.helper
|
||||
|
||||
import app.k9mail.core.android.common.contact.ContactRepository
|
||||
import net.thunderbird.core.common.mail.toEmailAddressOrNull
|
||||
|
||||
interface ContactNameProvider {
|
||||
fun getNameForAddress(address: String): String?
|
||||
}
|
||||
|
||||
class RealContactNameProvider(
|
||||
private val contactRepository: ContactRepository,
|
||||
) : ContactNameProvider {
|
||||
override fun getNameForAddress(address: String): String? {
|
||||
return address.toEmailAddressOrNull()?.let { emailAddress ->
|
||||
contactRepository.getContactFor(emailAddress)?.name
|
||||
}
|
||||
}
|
||||
}
|
||||
16
legacy/core/src/main/java/com/fsck/k9/helper/Contacts.kt
Normal file
16
legacy/core/src/main/java/com/fsck/k9/helper/Contacts.kt
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package com.fsck.k9.helper
|
||||
|
||||
import com.fsck.k9.mail.Address
|
||||
|
||||
/**
|
||||
* Helper class to access the contacts stored on the device.
|
||||
*/
|
||||
class Contacts {
|
||||
/**
|
||||
* Mark contacts with the provided email addresses as contacted.
|
||||
*/
|
||||
fun markAsContacted(addresses: Array<Address?>?) {
|
||||
// TODO: Keep track of this information in a local database. Then use this information when sorting contacts for
|
||||
// auto-completion.
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
@file:JvmName("CrLfConverter")
|
||||
|
||||
package com.fsck.k9.helper
|
||||
|
||||
fun String?.toLf() = this?.replace("\r\n", "\n")
|
||||
|
||||
fun CharSequence?.toLf() = this?.toString()?.replace("\r\n", "\n")
|
||||
|
||||
fun CharSequence?.toCrLf() = this?.toString()?.replace("\n", "\r\n")
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
package com.fsck.k9.helper
|
||||
|
||||
import android.content.Context
|
||||
import android.net.SSLCertificateSocketFactory
|
||||
import android.os.Build
|
||||
import android.text.TextUtils
|
||||
import com.fsck.k9.mail.ssl.TrustManagerFactory
|
||||
import com.fsck.k9.mail.ssl.TrustedSocketFactory
|
||||
import java.io.IOException
|
||||
import java.net.Socket
|
||||
import java.security.KeyManagementException
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import javax.net.ssl.KeyManager
|
||||
import javax.net.ssl.SNIHostName
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.TrustManager
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
import net.thunderbird.core.common.net.HostNameUtils.isLegalIPAddress
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
|
||||
class DefaultTrustedSocketFactory(
|
||||
private val context: Context?,
|
||||
private val trustManagerFactory: TrustManagerFactory,
|
||||
) : TrustedSocketFactory {
|
||||
|
||||
@Throws(
|
||||
NoSuchAlgorithmException::class,
|
||||
KeyManagementException::class,
|
||||
MessagingException::class,
|
||||
IOException::class,
|
||||
)
|
||||
override fun createSocket(socket: Socket?, host: String, port: Int, clientCertificateAlias: String?): Socket {
|
||||
val trustManagers = arrayOf<TrustManager?>(trustManagerFactory.getTrustManagerForDomain(host, port))
|
||||
var keyManagers: Array<KeyManager?>? = null
|
||||
if (!TextUtils.isEmpty(clientCertificateAlias)) {
|
||||
keyManagers = arrayOf<KeyManager?>(KeyChainKeyManager(context, clientCertificateAlias))
|
||||
}
|
||||
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(keyManagers, trustManagers, null)
|
||||
val socketFactory = sslContext.socketFactory
|
||||
val trustedSocket: Socket?
|
||||
if (socket == null) {
|
||||
trustedSocket = socketFactory.createSocket()
|
||||
} else {
|
||||
trustedSocket = socketFactory.createSocket(socket, host, port, true)
|
||||
}
|
||||
|
||||
val sslSocket = trustedSocket as SSLSocket
|
||||
|
||||
hardenSocket(sslSocket)
|
||||
|
||||
// RFC 6066 does not permit the use of literal IPv4 or IPv6 addresses as SNI hostnames.
|
||||
if (isLegalIPAddress(host) == null) {
|
||||
setSniHost(socketFactory, sslSocket, host)
|
||||
}
|
||||
|
||||
return trustedSocket
|
||||
}
|
||||
|
||||
private fun hardenSocket(sock: SSLSocket) {
|
||||
ENABLED_CIPHERS?.let { sock.enabledCipherSuites = it }
|
||||
ENABLED_PROTOCOLS?.let { sock.enabledProtocols = it }
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
companion object {
|
||||
private val DISALLOWED_CIPHERS = arrayOf<String>(
|
||||
"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 val DISALLOWED_PROTOCOLS = arrayOf<String>(
|
||||
"SSLv3",
|
||||
)
|
||||
|
||||
private val ENABLED_CIPHERS: Array<String>?
|
||||
private val ENABLED_PROTOCOLS: Array<String>?
|
||||
|
||||
init {
|
||||
var enabledCiphers: Array<String>? = null
|
||||
var supportedProtocols: Array<String>? = null
|
||||
|
||||
try {
|
||||
val sslContext = SSLContext.getInstance("TLS").apply {
|
||||
init(null, null, null)
|
||||
}
|
||||
val socket = sslContext.socketFactory.createSocket() as SSLSocket
|
||||
enabledCiphers = socket.enabledCipherSuites
|
||||
|
||||
/*
|
||||
* 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 = socket.supportedProtocols
|
||||
} catch (e: Exception) {
|
||||
Log.e(e, "Error getting information about available SSL/TLS ciphers and protocols")
|
||||
}
|
||||
|
||||
ENABLED_CIPHERS = enabledCiphers?.let { remove(it, DISALLOWED_CIPHERS) }
|
||||
ENABLED_PROTOCOLS = supportedProtocols?.let { remove(it, DISALLOWED_PROTOCOLS) }
|
||||
}
|
||||
|
||||
private fun remove(enabled: Array<String>, disallowed: Array<String>): Array<String> {
|
||||
return enabled.filterNot { it in disallowed }.toTypedArray()
|
||||
}
|
||||
|
||||
private fun setSniHost(factory: SSLSocketFactory, socket: SSLSocket, hostname: String) {
|
||||
when {
|
||||
factory is SSLCertificateSocketFactory -> factory.setHostname(socket, hostname)
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N -> {
|
||||
val sslParameters = socket.sslParameters
|
||||
sslParameters.serverNames = listOf(SNIHostName(hostname))
|
||||
socket.sslParameters = sslParameters
|
||||
}
|
||||
|
||||
else -> setHostnameViaReflection(socket, hostname)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setHostnameViaReflection(socket: SSLSocket, hostname: String?) {
|
||||
try {
|
||||
socket.javaClass.getMethod("setHostname", String::class.java).invoke(socket, hostname)
|
||||
} catch (e: Throwable) {
|
||||
Log.e(e, "Could not call SSLSocket#setHostname(String) method ")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
73
legacy/core/src/main/java/com/fsck/k9/helper/FileHelper.kt
Normal file
73
legacy/core/src/main/java/com/fsck/k9/helper/FileHelper.kt
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package com.fsck.k9.helper
|
||||
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
|
||||
object FileHelper {
|
||||
|
||||
@JvmStatic
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
fun touchFile(parentDir: File, name: String) {
|
||||
val file = File(parentDir, name)
|
||||
try {
|
||||
if (!file.exists()) {
|
||||
if (!file.createNewFile()) {
|
||||
Log.d("Unable to create file: %s", file.absolutePath)
|
||||
}
|
||||
} else {
|
||||
if (!file.setLastModified(System.currentTimeMillis())) {
|
||||
Log.d("Unable to change last modification date: %s", file.absolutePath)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(e, "Unable to touch file: %s", file.absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Throws(IOException::class)
|
||||
fun renameOrMoveByCopying(from: File, to: File) {
|
||||
deleteFileIfExists(to)
|
||||
val renameFailed = !from.renameTo(to)
|
||||
if (renameFailed) {
|
||||
from.copyTo(target = to, overwrite = true)
|
||||
val deleteFromFailed = !from.delete()
|
||||
if (deleteFromFailed) {
|
||||
Log.e("Unable to delete source file after copying to destination!")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun deleteFileIfExists(file: File) {
|
||||
if (file.exists() && !file.delete()) {
|
||||
throw IOException("Unable to delete file: ${file.absolutePath}")
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
fun move(from: File, to: File): Boolean {
|
||||
if (to.exists()) {
|
||||
if (!to.delete()) {
|
||||
Log.d("Unable to delete file: %s", to.absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
val parent = to.parentFile
|
||||
if (parent != null && !parent.mkdirs()) {
|
||||
Log.d("Unable to make directories: %s", parent.absolutePath)
|
||||
}
|
||||
return try {
|
||||
from.copyTo(target = to, overwrite = true)
|
||||
val deleteFromFailed = !from.delete()
|
||||
if (deleteFromFailed) {
|
||||
Log.e("Unable to delete source file after copying to destination!")
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.w(e, "cannot move %s to %s", from.absolutePath, to.absolutePath)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.fsck.k9.helper
|
||||
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.mail.Message.RecipientType
|
||||
import net.thunderbird.core.android.account.Identity
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
|
||||
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 LegacyAccount.findIdentity
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getRecipientIdentityFromMessage(account: LegacyAccount, 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
|
||||
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 com.fsck.k9.mail.ClientCertificateError;
|
||||
import com.fsck.k9.mail.ClientCertificateException;
|
||||
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 net.thunderbird.core.common.exception.MessagingException;
|
||||
import net.thunderbird.core.logging.legacy.Log;
|
||||
|
||||
|
||||
/**
|
||||
* 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 | InterruptedException e) {
|
||||
throw new ClientCertificateException(ClientCertificateError.RetrievalFailure, e);
|
||||
}
|
||||
}
|
||||
|
||||
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 ClientCertificateException(ClientCertificateError.CertificateExpired, e);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Log.w("Client certificate %s not issued by any of the requested issuers", mAlias);
|
||||
return null;
|
||||
}
|
||||
Log.w("Client certificate %s does not match any of the requested key types", mAlias);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
14
legacy/core/src/main/java/com/fsck/k9/helper/KoinModule.kt
Normal file
14
legacy/core/src/main/java/com/fsck/k9/helper/KoinModule.kt
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
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(), generalSettingsManager = get()) }
|
||||
factory<KeyStoreDirectoryProvider> { AndroidKeyStoreDirectoryProvider(context = get()) }
|
||||
factory { get<Context>().getSystemService(Context.ALARM_SERVICE) as AlarmManager }
|
||||
factory<ContactNameProvider> { RealContactNameProvider(contactRepository = get()) }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
165
legacy/core/src/main/java/com/fsck/k9/helper/MailTo.java
Normal file
165
legacy/core/src/main/java/com/fsck/k9/helper/MailTo.java
Normal 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 net.thunderbird.core.logging.legacy.Log;
|
||||
|
||||
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) {
|
||||
Log.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
162
legacy/core/src/main/java/com/fsck/k9/helper/MessageHelper.kt
Normal file
162
legacy/core/src/main/java/com/fsck/k9/helper/MessageHelper.kt
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
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 com.fsck.k9.CoreResourceProvider
|
||||
import com.fsck.k9.K9.contactNameColor
|
||||
import com.fsck.k9.mail.Address
|
||||
import java.util.regex.Pattern
|
||||
import net.thunderbird.core.common.mail.toEmailAddressOrNull
|
||||
import net.thunderbird.core.preference.GeneralSettingsManager
|
||||
|
||||
class MessageHelper(
|
||||
private val resourceProvider: CoreResourceProvider,
|
||||
private val contactRepository: ContactRepository,
|
||||
private val generalSettingsManager: GeneralSettingsManager,
|
||||
) {
|
||||
|
||||
fun getSenderDisplayName(address: Address?): CharSequence {
|
||||
if (address == null) {
|
||||
return resourceProvider.contactUnknownSender()
|
||||
}
|
||||
val repository = if (generalSettingsManager.getConfig().display.isShowContactName) contactRepository else null
|
||||
return toFriendly(
|
||||
address,
|
||||
generalSettingsManager.getConfig().display.isShowCorrespondentNames,
|
||||
generalSettingsManager.getConfig().display.isChangeContactNameColor,
|
||||
repository,
|
||||
)
|
||||
}
|
||||
|
||||
fun getRecipientDisplayNames(
|
||||
addresses: Array<Address>?,
|
||||
isShowCorrespondentNames: Boolean,
|
||||
isChangeContactNameColor: Boolean,
|
||||
): CharSequence {
|
||||
if (addresses == null || addresses.isEmpty()) {
|
||||
return resourceProvider.contactUnknownRecipient()
|
||||
}
|
||||
val repository = if (generalSettingsManager.getConfig().display.isShowContactName) contactRepository else null
|
||||
val recipients = toFriendly(addresses, isShowCorrespondentNames, isChangeContactNameColor, repository)
|
||||
return SpannableStringBuilder(resourceProvider.contactDisplayNamePrefix()).append(' ').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,
|
||||
isShowCorrespondentNames: Boolean,
|
||||
isChangeContactNameColor: Boolean,
|
||||
contactRepository: ContactRepository?,
|
||||
): CharSequence {
|
||||
return toFriendly(
|
||||
address,
|
||||
contactRepository,
|
||||
isShowCorrespondentNames,
|
||||
isChangeContactNameColor,
|
||||
contactNameColor,
|
||||
)
|
||||
}
|
||||
|
||||
fun toFriendly(
|
||||
addresses: Array<Address>?,
|
||||
isShowCorrespondentNames: Boolean,
|
||||
isChangeContactNameColor: Boolean,
|
||||
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],
|
||||
isShowCorrespondentNames,
|
||||
isChangeContactNameColor,
|
||||
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.getContactName(address)
|
||||
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 ContactRepository.getContactName(address: Address): String? {
|
||||
return address.address.toEmailAddressOrNull()?.let { emailAddress ->
|
||||
getContactFor(emailAddress)?.name
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSpoofAddress(displayName: String): Boolean {
|
||||
return displayName.contains("@") && SPOOF_ADDRESS_PATTERN.matcher(displayName).find()
|
||||
}
|
||||
}
|
||||
}
|
||||
925
legacy/core/src/main/java/com/fsck/k9/helper/MimeTypeUtil.java
Normal file
925
legacy/core/src/main/java/com/fsck/k9/helper/MimeTypeUtil.java
Normal 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.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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package com.fsck.k9.helper
|
||||
|
||||
class MutableBoolean(var value: Boolean)
|
||||
|
|
@ -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++}")
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
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.mail.Address;
|
||||
import com.fsck.k9.mail.Message;
|
||||
import com.fsck.k9.mail.Message.RecipientType;
|
||||
import net.thunderbird.core.android.account.LegacyAccount;
|
||||
|
||||
|
||||
public class ReplyToParser {
|
||||
|
||||
public ReplyToAddresses getRecipientsToReplyTo(Message message, LegacyAccount 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, LegacyAccount 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];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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 net.thunderbird.core.logging.legacy.Log;
|
||||
|
||||
|
||||
/**
|
||||
* 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()) {
|
||||
Log.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());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
@file:JvmName("StringHelper")
|
||||
|
||||
package com.fsck.k9.helper
|
||||
|
||||
fun isNullOrEmpty(text: String?) = text.isNullOrEmpty()
|
||||
22
legacy/core/src/main/java/com/fsck/k9/helper/Timing.kt
Normal file
22
legacy/core/src/main/java/com/fsck/k9/helper/Timing.kt
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.fsck.k9.helper;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URLEncoder;
|
||||
|
||||
/**
|
||||
* Wraps the java.net.URLEncoder to avoid unhelpful checked exceptions.
|
||||
*/
|
||||
public class UrlEncodingHelper {
|
||||
|
||||
public static String encodeUtf8(String s) {
|
||||
try {
|
||||
return URLEncoder.encode(s, "UTF-8");
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
/*
|
||||
* This is impossible, UTF-8 is always supported
|
||||
*/
|
||||
throw new RuntimeException("UTF-8 not found");
|
||||
}
|
||||
}
|
||||
}
|
||||
225
legacy/core/src/main/java/com/fsck/k9/helper/Utility.java
Normal file
225
legacy/core/src/main/java/com/fsck/k9/helper/Utility.java
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
|
||||
package com.fsck.k9.helper;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import android.database.Cursor;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import net.thunderbird.core.logging.legacy.Log;
|
||||
|
||||
public class Utility {
|
||||
|
||||
// \u00A0 (non-breaking space) happens to be used by French MUA
|
||||
|
||||
// Note: no longer using the ^ beginning character combined with (...)+
|
||||
// repetition matching as we might want to strip ML tags. Ex:
|
||||
// Re: [foo] Re: RE : [foo] blah blah blah
|
||||
private static final Pattern RESPONSE_PATTERN = Pattern.compile(
|
||||
"((Re|Fw|Fwd|Aw|R\\u00E9f\\.)(\\[\\d+\\])?[\\u00A0 ]?: *)+", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
/**
|
||||
* Mailing-list tag pattern to match strings like "[foobar] "
|
||||
*/
|
||||
private static final Pattern TAG_PATTERN = Pattern.compile("\\[[-_a-z0-9]+\\] ",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
|
||||
public static boolean arrayContains(Object[] a, Object o) {
|
||||
for (Object element : a) {
|
||||
if (element.equals(o)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean arrayContainsAny(Object[] a, Object... o) {
|
||||
for (Object element : a) {
|
||||
if (arrayContains(o, element)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines the given Objects into a single String using
|
||||
* each Object's toString() method and the separator character
|
||||
* between each part.
|
||||
*
|
||||
* @param parts
|
||||
* @param separator
|
||||
* @return new String
|
||||
*/
|
||||
public static String combine(Iterable<?> parts, char separator) {
|
||||
if (parts == null) {
|
||||
return null;
|
||||
}
|
||||
return TextUtils.join(String.valueOf(separator), parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the 'original' subject value, by ignoring leading
|
||||
* response/forward marker and '[XX]' formatted tags (as many mailing-list
|
||||
* software do).
|
||||
*
|
||||
* <p>
|
||||
* Result is also trimmed.
|
||||
* </p>
|
||||
*
|
||||
* @param subject
|
||||
* Never <code>null</code>.
|
||||
* @return Never <code>null</code>.
|
||||
*/
|
||||
public static String stripSubject(final String subject) {
|
||||
int lastPrefix = 0;
|
||||
|
||||
final Matcher tagMatcher = TAG_PATTERN.matcher(subject);
|
||||
String tag = null;
|
||||
// whether tag stripping logic should be active
|
||||
boolean tagPresent = false;
|
||||
// whether the last action stripped a tag
|
||||
boolean tagStripped = false;
|
||||
if (tagMatcher.find(0)) {
|
||||
tagPresent = true;
|
||||
if (tagMatcher.start() == 0) {
|
||||
// found at beginning of subject, considering it an actual tag
|
||||
tag = tagMatcher.group();
|
||||
|
||||
// now need to find response marker after that tag
|
||||
lastPrefix = tagMatcher.end();
|
||||
tagStripped = true;
|
||||
}
|
||||
}
|
||||
|
||||
final Matcher matcher = RESPONSE_PATTERN.matcher(subject);
|
||||
|
||||
// while:
|
||||
// - lastPrefix is within the bounds
|
||||
// - response marker found at lastPrefix position
|
||||
// (to make sure we don't catch response markers that are part of
|
||||
// the actual subject)
|
||||
|
||||
while (lastPrefix < subject.length() - 1
|
||||
&& matcher.find(lastPrefix)
|
||||
&& matcher.start() == lastPrefix
|
||||
&& (!tagPresent || tag == null || subject.regionMatches(matcher.end(), tag, 0,
|
||||
tag.length()))) {
|
||||
lastPrefix = matcher.end();
|
||||
|
||||
if (tagPresent) {
|
||||
tagStripped = false;
|
||||
if (tag == null) {
|
||||
// attempt to find tag
|
||||
if (tagMatcher.start() == lastPrefix) {
|
||||
tag = tagMatcher.group();
|
||||
lastPrefix += tag.length();
|
||||
tagStripped = true;
|
||||
}
|
||||
} else if (lastPrefix < subject.length() - 1 && subject.startsWith(tag, lastPrefix)) {
|
||||
// Re: [foo] Re: [foo] blah blah blah
|
||||
// ^ ^
|
||||
// ^ ^
|
||||
// ^ new position
|
||||
// ^
|
||||
// initial position
|
||||
lastPrefix += tag.length();
|
||||
tagStripped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Null pointer check is to make the static analysis component of Eclipse happy.
|
||||
if (tagStripped && (tag != null)) {
|
||||
// restore the last tag
|
||||
lastPrefix -= tag.length();
|
||||
}
|
||||
if (lastPrefix > -1 && lastPrefix < subject.length() - 1) {
|
||||
return subject.substring(lastPrefix).trim();
|
||||
} else {
|
||||
return subject.trim();
|
||||
}
|
||||
}
|
||||
|
||||
public static String stripNewLines(String multiLineString) {
|
||||
return multiLineString.replaceAll("[\\r\\n]", "");
|
||||
}
|
||||
|
||||
|
||||
private static final String IMG_SRC_REGEX = "(?is:<img[^>]+src\\s*=\\s*['\"]?([a-z]+)\\:)";
|
||||
private static final Pattern IMG_PATTERN = Pattern.compile(IMG_SRC_REGEX);
|
||||
/**
|
||||
* Figure out if this part has images.
|
||||
* TODO: should only return true if we're an html part
|
||||
* @param message Content to evaluate
|
||||
* @return True if it has external images; false otherwise.
|
||||
*/
|
||||
public static boolean hasExternalImages(final String message) {
|
||||
Matcher imgMatches = IMG_PATTERN.matcher(message);
|
||||
while (imgMatches.find()) {
|
||||
String uriScheme = imgMatches.group(1);
|
||||
if (uriScheme.equals("http") || uriScheme.equals("https")) {
|
||||
Log.d("External images found");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Log.d("No external images.");
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unconditionally close a Cursor. Equivalent to {@link Cursor#close()},
|
||||
* if cursor is non-null. This is typically used in finally blocks.
|
||||
*
|
||||
* @param cursor cursor to close
|
||||
*/
|
||||
public static void closeQuietly(final Cursor cursor) {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static final Pattern MESSAGE_ID = Pattern.compile("<" +
|
||||
"(?:" +
|
||||
"[a-zA-Z0-9!#$%&'*+\\-/=?^_`{|}~]+" +
|
||||
"(?:\\.[a-zA-Z0-9!#$%&'*+\\-/=?^_`{|}~]+)*" +
|
||||
"|" +
|
||||
"\"(?:[^\\\\\"]|\\\\.)*\"" +
|
||||
")" +
|
||||
"@" +
|
||||
"(?:" +
|
||||
"[a-zA-Z0-9!#$%&'*+\\-/=?^_`{|}~]+" +
|
||||
"(?:\\.[a-zA-Z0-9!#$%&'*+\\-/=?^_`{|}~]+)*" +
|
||||
"|" +
|
||||
"\\[(?:[^\\\\\\]]|\\\\.)*\\]" +
|
||||
")" +
|
||||
">");
|
||||
|
||||
public static List<String> extractMessageIds(final String text) {
|
||||
List<String> messageIds = new ArrayList<>();
|
||||
Matcher matcher = MESSAGE_ID.matcher(text);
|
||||
|
||||
int start = 0;
|
||||
while (matcher.find(start)) {
|
||||
String messageId = text.substring(matcher.start(), matcher.end());
|
||||
messageIds.add(messageId);
|
||||
start = matcher.end();
|
||||
}
|
||||
|
||||
return messageIds;
|
||||
}
|
||||
|
||||
public static String extractMessageId(final String text) {
|
||||
Matcher matcher = MESSAGE_ID.matcher(text);
|
||||
|
||||
if (matcher.find()) {
|
||||
return text.substring(matcher.start(), matcher.end());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
/*
|
||||
* The MIT License
|
||||
*
|
||||
* © 2009-2017, Jonathan Hedley <jonathan@hedley.net>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
package com.fsck.k9.helper.jsoup;
|
||||
|
||||
|
||||
import com.fsck.k9.helper.jsoup.NodeFilter.HeadFilterDecision;
|
||||
import com.fsck.k9.helper.jsoup.NodeFilter.TailFilterDecision;
|
||||
import org.jsoup.nodes.Node;
|
||||
import org.jsoup.select.NodeTraversor;
|
||||
|
||||
|
||||
/**
|
||||
* Depth-first node traversor.
|
||||
* <p>
|
||||
* Based on {@link NodeTraversor}, but supports skipping sub trees, removing nodes, and stopping the traversal at any
|
||||
* point.
|
||||
* </p><p>
|
||||
* This is an enhancement of the <a href="https://github.com/jhy/jsoup/pull/849">jsoup pull request 'Improved node
|
||||
* traversal'</a> by <a href="https://github.com/kno10">Erich Schubert</a>.
|
||||
* </p>
|
||||
*/
|
||||
public class AdvancedNodeTraversor {
|
||||
/**
|
||||
* Filter result.
|
||||
*/
|
||||
public enum FilterResult {
|
||||
/**
|
||||
* Processing the tree was completed.
|
||||
*/
|
||||
ENDED,
|
||||
/**
|
||||
* Processing was stopped.
|
||||
*/
|
||||
STOPPED,
|
||||
/**
|
||||
* Processing the tree was completed and the root node was removed.
|
||||
*/
|
||||
ROOT_REMOVED
|
||||
}
|
||||
|
||||
private NodeFilter filter;
|
||||
|
||||
/**
|
||||
* Create a new traversor.
|
||||
*
|
||||
* @param filter
|
||||
* a class implementing the {@link NodeFilter} interface, to be called when visiting each node.
|
||||
*/
|
||||
public AdvancedNodeTraversor(NodeFilter filter) {
|
||||
this.filter = filter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a depth-first filtering of the root and all of its descendants.
|
||||
*
|
||||
* @param root
|
||||
* the root node point to traverse.
|
||||
*
|
||||
* @return The result of the filter operation.
|
||||
*/
|
||||
public FilterResult filter(Node root) {
|
||||
Node node = root;
|
||||
int depth = 0;
|
||||
|
||||
while (node != null) {
|
||||
HeadFilterDecision headResult = filter.head(node, depth);
|
||||
if (headResult == HeadFilterDecision.STOP) {
|
||||
return FilterResult.STOPPED;
|
||||
}
|
||||
|
||||
if (headResult == HeadFilterDecision.CONTINUE && node.childNodeSize() > 0) {
|
||||
node = node.childNode(0);
|
||||
++depth;
|
||||
continue;
|
||||
}
|
||||
|
||||
TailFilterDecision tailResult = TailFilterDecision.CONTINUE;
|
||||
while (node.nextSibling() == null && depth > 0) {
|
||||
if (headResult == HeadFilterDecision.CONTINUE || headResult == HeadFilterDecision.SKIP_CHILDREN) {
|
||||
tailResult = filter.tail(node, depth);
|
||||
if (tailResult == TailFilterDecision.STOP) {
|
||||
return FilterResult.STOPPED;
|
||||
}
|
||||
}
|
||||
|
||||
Node prev = node;
|
||||
node = node.parentNode();
|
||||
depth--;
|
||||
|
||||
if (headResult == HeadFilterDecision.REMOVE || tailResult == TailFilterDecision.REMOVE) {
|
||||
prev.remove();
|
||||
}
|
||||
|
||||
headResult = HeadFilterDecision.CONTINUE;
|
||||
}
|
||||
|
||||
if (headResult == HeadFilterDecision.CONTINUE || headResult == HeadFilterDecision.SKIP_CHILDREN) {
|
||||
tailResult = filter.tail(node, depth);
|
||||
if (tailResult == TailFilterDecision.STOP) {
|
||||
return FilterResult.STOPPED;
|
||||
}
|
||||
}
|
||||
|
||||
Node prev = node;
|
||||
node = node.nextSibling();
|
||||
|
||||
if (headResult == HeadFilterDecision.REMOVE || tailResult == TailFilterDecision.REMOVE) {
|
||||
prev.remove();
|
||||
}
|
||||
|
||||
if (prev == root) {
|
||||
return headResult == HeadFilterDecision.REMOVE ? FilterResult.ROOT_REMOVED : FilterResult.ENDED;
|
||||
}
|
||||
}
|
||||
|
||||
return FilterResult.ENDED;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
package com.fsck.k9.helper.jsoup;
|
||||
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.jsoup.nodes.Node;
|
||||
|
||||
|
||||
/**
|
||||
* Node filter interface. Provide an implementing class to {@link AdvancedNodeTraversor} to iterate through
|
||||
* nodes.
|
||||
* <p>
|
||||
* This interface provides two methods, {@code head} and {@code tail}. The head method is called when the node is first
|
||||
* seen, and the tail method when all of the node's children have been visited. As an example, head can be used to
|
||||
* create a start tag for a node, and tail to create the end tag.
|
||||
* </p>
|
||||
* <p>
|
||||
* For every node, the filter has to decide in {@link NodeFilter#head(Node, int)}) whether to
|
||||
* <ul>
|
||||
* <li>continue ({@link HeadFilterDecision#CONTINUE}),</li>
|
||||
* <li>skip all children ({@link HeadFilterDecision#SKIP_CHILDREN}),</li>
|
||||
* <li>skip node entirely ({@link HeadFilterDecision#SKIP_ENTIRELY}),</li>
|
||||
* <li>remove the subtree ({@link HeadFilterDecision#REMOVE}),</li>
|
||||
* <li>interrupt the iteration and return ({@link HeadFilterDecision#STOP}).</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* The difference between {@link HeadFilterDecision#SKIP_CHILDREN} and {@link HeadFilterDecision#SKIP_ENTIRELY} is that
|
||||
* the first will invoke {@link NodeFilter#tail(Node, int)} on the node, while the latter will not.
|
||||
* </p>
|
||||
* <p>
|
||||
* When {@link NodeFilter#tail(Node, int)} is called the filter has to decide whether to
|
||||
* <ul>
|
||||
* <li>continue ({@link TailFilterDecision#CONTINUE}),</li>
|
||||
* <li>remove the subtree ({@link TailFilterDecision#REMOVE}),</li>
|
||||
* <li>interrupt the iteration and return ({@link TailFilterDecision#STOP}).</li>
|
||||
* </ul>
|
||||
* </p>
|
||||
*/
|
||||
public interface NodeFilter {
|
||||
/**
|
||||
* Filter decision for {@link NodeFilter#head(Node, int)}.
|
||||
*/
|
||||
enum HeadFilterDecision {
|
||||
/**
|
||||
* Continue processing the tree.
|
||||
*/
|
||||
CONTINUE,
|
||||
/**
|
||||
* Skip the child nodes, but do call {@link NodeFilter#tail(Node, int)} next.
|
||||
*/
|
||||
SKIP_CHILDREN,
|
||||
/**
|
||||
* Skip the subtree, and do not call {@link NodeFilter#tail(Node, int)}.
|
||||
*/
|
||||
SKIP_ENTIRELY,
|
||||
/**
|
||||
* Remove the node and its children, and do not call {@link NodeFilter#tail(Node, int)}.
|
||||
*/
|
||||
REMOVE,
|
||||
/**
|
||||
* Stop processing.
|
||||
*/
|
||||
STOP
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter decision for {@link NodeFilter#tail(Node, int)}.
|
||||
*/
|
||||
enum TailFilterDecision {
|
||||
/**
|
||||
* Continue processing the tree.
|
||||
*/
|
||||
CONTINUE,
|
||||
/**
|
||||
* Remove the node and its children.
|
||||
*/
|
||||
REMOVE,
|
||||
/**
|
||||
* Stop processing.
|
||||
*/
|
||||
STOP
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for when a node is first visited.
|
||||
*
|
||||
* @param node
|
||||
* the node being visited.
|
||||
* @param depth
|
||||
* the depth of the node, relative to the root node. E.g., the root node has depth 0, and a child node
|
||||
* of that will have depth 1.
|
||||
*
|
||||
* @return Filter decision
|
||||
*/
|
||||
@NonNull
|
||||
HeadFilterDecision head(Node node, int depth);
|
||||
|
||||
/**
|
||||
* Callback for when a node is last visited, after all of its descendants have been visited.
|
||||
*
|
||||
* @param node
|
||||
* the node being visited.
|
||||
* @param depth
|
||||
* the depth of the node, relative to the root node. E.g., the root node has depth 0, and a child node
|
||||
* of that will have depth 1.
|
||||
*
|
||||
* @return Filter decision
|
||||
*/
|
||||
@NonNull
|
||||
TailFilterDecision tail(Node node, int depth);
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package com.fsck.k9.job
|
||||
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
class FileLogLimitWorkManager(
|
||||
private val workManager: WorkManager,
|
||||
) {
|
||||
fun scheduleFileLogTimeLimit(contentUriString: String): Flow<WorkInfo?> {
|
||||
val data = workDataOf("exportUriString" to contentUriString)
|
||||
val workRequest: OneTimeWorkRequest =
|
||||
OneTimeWorkRequestBuilder<SyncDebugWorker>()
|
||||
.setInitialDelay(TWENTY_FOUR_HOURS, TimeUnit.HOURS)
|
||||
.setInputData(data)
|
||||
.addTag(SYNC_TAG)
|
||||
.setBackoffCriteria(BackoffPolicy.LINEAR, INITIAL_BACKOFF_DELAY_MINUTES, TimeUnit.MINUTES)
|
||||
.build()
|
||||
workManager.enqueueUniqueWork(SYNC_TAG, ExistingWorkPolicy.REPLACE, workRequest)
|
||||
return workManager.getWorkInfoByIdFlow(workRequest.id)
|
||||
}
|
||||
|
||||
fun cancelFileLogTimeLimit() {
|
||||
workManager.cancelUniqueWork(SYNC_TAG)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SYNC_TAG = "sync_debug_timer"
|
||||
private const val INITIAL_BACKOFF_DELAY_MINUTES = 5L
|
||||
private const val TWENTY_FOUR_HOURS = 24L
|
||||
}
|
||||
}
|
||||
46
legacy/core/src/main/java/com/fsck/k9/job/K9JobManager.kt
Normal file
46
legacy/core/src/main/java/com/fsck/k9/job/K9JobManager.kt
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package com.fsck.k9.job
|
||||
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import net.thunderbird.core.android.account.AccountManager
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
|
||||
class K9JobManager(
|
||||
private val workManager: WorkManager,
|
||||
private val accountManager: AccountManager,
|
||||
private val mailSyncWorkerManager: MailSyncWorkerManager,
|
||||
private val syncDebugFileLogManager: FileLogLimitWorkManager,
|
||||
) {
|
||||
fun scheduleDebugLogLimit(contentUriString: String): Flow<WorkInfo?> {
|
||||
return syncDebugFileLogManager.scheduleFileLogTimeLimit(contentUriString)
|
||||
}
|
||||
|
||||
fun cancelDebugLogLimit() {
|
||||
syncDebugFileLogManager.cancelFileLogTimeLimit()
|
||||
}
|
||||
|
||||
fun scheduleAllMailJobs() {
|
||||
Log.v("scheduling all jobs")
|
||||
scheduleMailSync()
|
||||
}
|
||||
|
||||
fun scheduleMailSync(account: LegacyAccount) {
|
||||
mailSyncWorkerManager.cancelMailSync(account)
|
||||
mailSyncWorkerManager.scheduleMailSync(account)
|
||||
}
|
||||
|
||||
private fun scheduleMailSync() {
|
||||
cancelAllMailSyncJobs()
|
||||
|
||||
accountManager.getAccounts().forEach { account ->
|
||||
mailSyncWorkerManager.scheduleMailSync(account)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelAllMailSyncJobs() {
|
||||
Log.v("canceling mail sync job")
|
||||
workManager.cancelAllWorkByTag(MailSyncWorkerManager.MAIL_SYNC_TAG)
|
||||
}
|
||||
}
|
||||
24
legacy/core/src/main/java/com/fsck/k9/job/K9WorkerFactory.kt
Normal file
24
legacy/core/src/main/java/com/fsck/k9/job/K9WorkerFactory.kt
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package com.fsck.k9.job
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.WorkerFactory
|
||||
import androidx.work.WorkerParameters
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import org.koin.java.KoinJavaComponent.getKoin
|
||||
|
||||
class K9WorkerFactory : WorkerFactory() {
|
||||
override fun createWorker(
|
||||
appContext: Context,
|
||||
workerClassName: String,
|
||||
workerParameters: WorkerParameters,
|
||||
): ListenableWorker? {
|
||||
// Don't attempt to load classes outside of our namespace.
|
||||
if (!workerClassName.startsWith("com.fsck.k9")) {
|
||||
return null
|
||||
}
|
||||
|
||||
val workerClass = Class.forName(workerClassName).kotlin
|
||||
return getKoin().getOrNull(workerClass) { parametersOf(workerParameters) }
|
||||
}
|
||||
}
|
||||
57
legacy/core/src/main/java/com/fsck/k9/job/KoinModule.kt
Normal file
57
legacy/core/src/main/java/com/fsck/k9/job/KoinModule.kt
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package com.fsck.k9.job
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerFactory
|
||||
import androidx.work.WorkerParameters
|
||||
import kotlin.time.ExperimentalTime
|
||||
import net.thunderbird.core.logging.Logger
|
||||
import net.thunderbird.core.logging.composite.CompositeLogSink
|
||||
import net.thunderbird.core.logging.file.FileLogSink
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
|
||||
val jobModule = module {
|
||||
single { WorkManagerConfigurationProvider(workerFactory = get()) }
|
||||
single<WorkerFactory> { K9WorkerFactory() }
|
||||
single { WorkManager.getInstance(get<Context>()) }
|
||||
single {
|
||||
K9JobManager(
|
||||
workManager = get(),
|
||||
accountManager = get(),
|
||||
mailSyncWorkerManager = get(),
|
||||
syncDebugFileLogManager = get(),
|
||||
)
|
||||
}
|
||||
factory {
|
||||
@OptIn(ExperimentalTime::class)
|
||||
MailSyncWorkerManager(
|
||||
workManager = get(),
|
||||
clock = get(),
|
||||
syncDebugLogger = get<Logger>(named("syncDebug")),
|
||||
generalSettingsManager = get(),
|
||||
)
|
||||
}
|
||||
factory { (parameters: WorkerParameters) ->
|
||||
MailSyncWorker(
|
||||
messagingController = get(),
|
||||
preferences = get(),
|
||||
context = get(),
|
||||
generalSettingsManager = get(),
|
||||
parameters = parameters,
|
||||
)
|
||||
}
|
||||
factory {
|
||||
FileLogLimitWorkManager(workManager = get())
|
||||
}
|
||||
factory { (parameters: WorkerParameters) ->
|
||||
SyncDebugWorker(
|
||||
context = get(),
|
||||
baseLogger = get<Logger>(),
|
||||
fileLogSink = get<FileLogSink>(named("syncDebug")),
|
||||
syncDebugCompositeSink = get<CompositeLogSink>(named("syncDebug")),
|
||||
generalSettingsManager = get(),
|
||||
parameters = parameters,
|
||||
)
|
||||
}
|
||||
}
|
||||
75
legacy/core/src/main/java/com/fsck/k9/job/MailSyncWorker.kt
Normal file
75
legacy/core/src/main/java/com/fsck/k9/job/MailSyncWorker.kt
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package com.fsck.k9.job
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.fsck.k9.Preferences
|
||||
import com.fsck.k9.controller.MessagingController
|
||||
import com.fsck.k9.mail.AuthType
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import net.thunderbird.core.preference.BackgroundOps
|
||||
import net.thunderbird.core.preference.GeneralSettingsManager
|
||||
|
||||
// IMPORTANT: Update K9WorkerFactory when moving this class and the FQCN no longer starts with "com.fsck.k9".
|
||||
class MailSyncWorker(
|
||||
private val messagingController: MessagingController,
|
||||
private val preferences: Preferences,
|
||||
private val generalSettingsManager: GeneralSettingsManager,
|
||||
context: Context,
|
||||
parameters: WorkerParameters,
|
||||
) : Worker(context, parameters) {
|
||||
|
||||
override fun doWork(): Result {
|
||||
val accountUuid = inputData.getString(EXTRA_ACCOUNT_UUID)
|
||||
requireNotNull(accountUuid)
|
||||
|
||||
Log.d("Executing periodic mail sync for account %s", accountUuid)
|
||||
|
||||
if (isBackgroundSyncDisabled()) {
|
||||
Log.d("Background sync is disabled. Skipping mail sync.")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
val account = preferences.getAccount(accountUuid)
|
||||
if (account == null) {
|
||||
Log.e("Account %s not found. Can't perform mail sync.", accountUuid)
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
if (account.isPeriodicMailSyncDisabled) {
|
||||
Log.d("Periodic mail sync has been disabled for this account. Skipping mail sync.")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
if (account.incomingServerSettings.isMissingCredentials) {
|
||||
Log.d("Password for this account is missing. Skipping mail sync.")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
if (account.incomingServerSettings.authenticationType == AuthType.XOAUTH2 && account.oAuthState == null) {
|
||||
Log.d("Account requires sign-in. Skipping mail sync.")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
val success = messagingController.performPeriodicMailSync(account)
|
||||
|
||||
return if (success) Result.success() else Result.retry()
|
||||
}
|
||||
|
||||
private fun isBackgroundSyncDisabled(): Boolean {
|
||||
return when (generalSettingsManager.getConfig().network.backgroundOps) {
|
||||
BackgroundOps.NEVER -> true
|
||||
BackgroundOps.ALWAYS -> false
|
||||
BackgroundOps.WHEN_CHECKED_AUTO_SYNC -> !ContentResolver.getMasterSyncAutomatically()
|
||||
}
|
||||
}
|
||||
|
||||
private val LegacyAccount.isPeriodicMailSyncDisabled
|
||||
get() = automaticCheckIntervalMinutes <= 0
|
||||
|
||||
companion object {
|
||||
const val EXTRA_ACCOUNT_UUID = "accountUuid"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
package com.fsck.k9.job
|
||||
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
import net.thunderbird.core.logging.Logger
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import net.thunderbird.core.preference.BackgroundOps
|
||||
import net.thunderbird.core.preference.GeneralSettingsManager
|
||||
|
||||
class MailSyncWorkerManager
|
||||
@OptIn(ExperimentalTime::class)
|
||||
constructor(
|
||||
private val workManager: WorkManager,
|
||||
val clock: Clock,
|
||||
val syncDebugLogger: Logger,
|
||||
val generalSettingsManager: GeneralSettingsManager,
|
||||
) {
|
||||
|
||||
fun cancelMailSync(account: LegacyAccount) {
|
||||
Log.v("Canceling mail sync worker for %s", account)
|
||||
val uniqueWorkName = createUniqueWorkName(account.uuid)
|
||||
workManager.cancelUniqueWork(uniqueWorkName)
|
||||
}
|
||||
|
||||
fun scheduleMailSync(account: LegacyAccount) {
|
||||
if (isNeverSyncInBackground()) return
|
||||
|
||||
getSyncIntervalIfEnabled(account)?.let { syncIntervalMinutes ->
|
||||
Log.v("Scheduling mail sync worker for %s", account)
|
||||
Log.v(" sync interval: %d minutes", syncIntervalMinutes)
|
||||
syncDebugLogger.info(null, null) { "Scheduling mail sync worker $account" }
|
||||
syncDebugLogger.info(null, null) { " sync interval: $syncIntervalMinutes minutes\"" }
|
||||
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.setRequiresStorageNotLow(true)
|
||||
.build()
|
||||
|
||||
val lastSyncTime = account.lastSyncTime
|
||||
Log.v(" last sync time: %tc", lastSyncTime)
|
||||
syncDebugLogger.info(null, null) { "last sync time: $lastSyncTime" }
|
||||
|
||||
val initialDelay = calculateInitialDelay(lastSyncTime, syncIntervalMinutes)
|
||||
Log.v(" initial delay: %d ms", initialDelay)
|
||||
syncDebugLogger.info(null, null) { " initial delay: $initialDelay ms" }
|
||||
|
||||
val data = workDataOf(MailSyncWorker.EXTRA_ACCOUNT_UUID to account.uuid)
|
||||
|
||||
val mailSyncRequest = PeriodicWorkRequestBuilder<MailSyncWorker>(syncIntervalMinutes, TimeUnit.MINUTES)
|
||||
.setInitialDelay(initialDelay, TimeUnit.MILLISECONDS)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, INITIAL_BACKOFF_DELAY_MINUTES, TimeUnit.MINUTES)
|
||||
.setConstraints(constraints)
|
||||
.setInputData(data)
|
||||
.addTag(MAIL_SYNC_TAG)
|
||||
.build()
|
||||
|
||||
val uniqueWorkName = createUniqueWorkName(account.uuid)
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
uniqueWorkName,
|
||||
ExistingPeriodicWorkPolicy.REPLACE,
|
||||
mailSyncRequest,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isNeverSyncInBackground() =
|
||||
generalSettingsManager.getConfig().network.backgroundOps == BackgroundOps.NEVER
|
||||
|
||||
private fun getSyncIntervalIfEnabled(account: LegacyAccount): Long? {
|
||||
val intervalMinutes = account.automaticCheckIntervalMinutes
|
||||
if (intervalMinutes <= LegacyAccount.INTERVAL_MINUTES_NEVER) {
|
||||
return null
|
||||
}
|
||||
|
||||
return intervalMinutes.toLong()
|
||||
}
|
||||
|
||||
private fun calculateInitialDelay(lastSyncTime: Long, syncIntervalMinutes: Long): Long {
|
||||
@OptIn(ExperimentalTime::class)
|
||||
val now = clock.now().toEpochMilliseconds()
|
||||
val nextSyncTime = lastSyncTime + (syncIntervalMinutes * 60L * 1000L)
|
||||
|
||||
return if (lastSyncTime > now || nextSyncTime <= now) {
|
||||
0L
|
||||
} else {
|
||||
nextSyncTime - now
|
||||
}
|
||||
}
|
||||
|
||||
private fun createUniqueWorkName(accountUuid: String): String {
|
||||
return "$MAIL_SYNC_TAG:$accountUuid"
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MAIL_SYNC_TAG = "MailSync"
|
||||
private const val INITIAL_BACKOFF_DELAY_MINUTES = 5L
|
||||
}
|
||||
}
|
||||
35
legacy/core/src/main/java/com/fsck/k9/job/SyncDebugWorker.kt
Normal file
35
legacy/core/src/main/java/com/fsck/k9/job/SyncDebugWorker.kt
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package com.fsck.k9.job
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import java.io.IOException
|
||||
import net.thunderbird.core.logging.Logger
|
||||
import net.thunderbird.core.logging.composite.CompositeLogSink
|
||||
import net.thunderbird.core.logging.file.FileLogSink
|
||||
import net.thunderbird.core.preference.GeneralSettingsManager
|
||||
import net.thunderbird.core.preference.update
|
||||
|
||||
class SyncDebugWorker(
|
||||
context: Context,
|
||||
val baseLogger: Logger,
|
||||
val fileLogSink: FileLogSink,
|
||||
val syncDebugCompositeSink: CompositeLogSink,
|
||||
parameters: WorkerParameters,
|
||||
val generalSettingsManager: GeneralSettingsManager,
|
||||
) : CoroutineWorker(context, parameters) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
try {
|
||||
fileLogSink.export(inputData.getString("exportUriString").toString())
|
||||
} catch (e: IOException) {
|
||||
baseLogger.error(message = { "Failed to export log" }, throwable = e)
|
||||
return Result.failure()
|
||||
}
|
||||
syncDebugCompositeSink.manager.remove(fileLogSink)
|
||||
generalSettingsManager.update { settings ->
|
||||
settings.copy(debugging = settings.debugging.copy(isSyncLoggingEnabled = false))
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.fsck.k9.job
|
||||
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkerFactory
|
||||
|
||||
class WorkManagerConfigurationProvider(private val workerFactory: WorkerFactory) {
|
||||
fun getConfiguration(): Configuration {
|
||||
return Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.fsck.k9.mailstore
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
|
||||
internal class AndroidStorageFilesProvider(
|
||||
private val context: Context,
|
||||
private val accountId: String,
|
||||
) : StorageFilesProvider {
|
||||
override fun getDatabaseFile(): File {
|
||||
return context.getDatabasePath("$accountId.db")
|
||||
}
|
||||
|
||||
override fun getAttachmentDirectory(): File {
|
||||
return context.getDatabasePath("$accountId.db_att")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.fsck.k9.mailstore
|
||||
|
||||
import android.content.Context
|
||||
|
||||
class AndroidStorageFilesProviderFactory(
|
||||
private val context: Context,
|
||||
) : StorageFilesProviderFactory {
|
||||
override fun createStorageFilesProvider(accountId: String): StorageFilesProvider {
|
||||
return AndroidStorageFilesProvider(context, accountId)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
package com.fsck.k9.mailstore;
|
||||
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Stack;
|
||||
|
||||
import android.net.Uri;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import app.k9mail.legacy.di.DI;
|
||||
import net.thunderbird.core.logging.legacy.Log;
|
||||
|
||||
import com.fsck.k9.mail.Body;
|
||||
import net.thunderbird.core.common.exception.MessagingException;
|
||||
import com.fsck.k9.mail.Multipart;
|
||||
import com.fsck.k9.mail.Part;
|
||||
import com.fsck.k9.message.extractors.AttachmentInfoExtractor;
|
||||
|
||||
|
||||
/**
|
||||
* This class is used to encapsulate a message part, providing an interface to
|
||||
* get relevant info for a given Content-ID URI.
|
||||
*
|
||||
* The point of this class is to keep the Content-ID loading code agnostic of
|
||||
* the underlying part structure.
|
||||
*/
|
||||
public class AttachmentResolver {
|
||||
Map<String,Uri> contentIdToAttachmentUriMap;
|
||||
|
||||
|
||||
private AttachmentResolver(Map<String, Uri> contentIdToAttachmentUriMap) {
|
||||
this.contentIdToAttachmentUriMap = contentIdToAttachmentUriMap;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Uri getAttachmentUriForContentId(String cid) {
|
||||
return contentIdToAttachmentUriMap.get(cid);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static AttachmentResolver createFromPart(Part part) {
|
||||
AttachmentInfoExtractor attachmentInfoExtractor = DI.get(AttachmentInfoExtractor.class);
|
||||
Map<String, Uri> contentIdToAttachmentUriMap = buildCidToAttachmentUriMap(attachmentInfoExtractor, part);
|
||||
return new AttachmentResolver(contentIdToAttachmentUriMap);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static Map<String,Uri> buildCidToAttachmentUriMap(AttachmentInfoExtractor attachmentInfoExtractor,
|
||||
Part rootPart) {
|
||||
HashMap<String,Uri> result = new HashMap<>();
|
||||
|
||||
Stack<Part> partsToCheck = new Stack<>();
|
||||
partsToCheck.push(rootPart);
|
||||
|
||||
while (!partsToCheck.isEmpty()) {
|
||||
Part part = partsToCheck.pop();
|
||||
|
||||
Body body = part.getBody();
|
||||
if (body instanceof Multipart) {
|
||||
Multipart multipart = (Multipart) body;
|
||||
for (Part bodyPart : multipart.getBodyParts()) {
|
||||
partsToCheck.push(bodyPart);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
String contentId = part.getContentId();
|
||||
if (contentId != null) {
|
||||
AttachmentViewInfo attachmentInfo = attachmentInfoExtractor.extractAttachmentInfo(part);
|
||||
result.put(contentId, attachmentInfo.internalUri);
|
||||
}
|
||||
} catch (MessagingException e) {
|
||||
Log.e(e, "Error extracting attachment info");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Collections.unmodifiableMap(result);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package com.fsck.k9.mailstore;
|
||||
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import com.fsck.k9.helper.MimeTypeUtil;
|
||||
import com.fsck.k9.mail.Part;
|
||||
|
||||
|
||||
public class AttachmentViewInfo {
|
||||
public static final long UNKNOWN_SIZE = -1;
|
||||
|
||||
public final String mimeType;
|
||||
public final String displayName;
|
||||
public final long size;
|
||||
|
||||
/**
|
||||
* A content provider URI that can be used to retrieve the decoded attachment.
|
||||
* <p/>
|
||||
* Note: All content providers must support an alternative MIME type appended as last URI segment.
|
||||
*/
|
||||
public final Uri internalUri;
|
||||
public final boolean inlineAttachment;
|
||||
public final Part part;
|
||||
private boolean contentAvailable;
|
||||
|
||||
public AttachmentViewInfo(String mimeType, String displayName, long size, Uri internalUri, boolean inlineAttachment,
|
||||
Part part, boolean contentAvailable) {
|
||||
this.mimeType = mimeType;
|
||||
this.displayName = displayName;
|
||||
this.size = size;
|
||||
this.internalUri = internalUri;
|
||||
this.inlineAttachment = inlineAttachment;
|
||||
this.part = part;
|
||||
this.contentAvailable = contentAvailable;
|
||||
}
|
||||
|
||||
public boolean isContentAvailable() {
|
||||
return contentAvailable;
|
||||
}
|
||||
|
||||
public void setContentAvailable() {
|
||||
this.contentAvailable = true;
|
||||
}
|
||||
|
||||
public boolean isSupportedImage() {
|
||||
if (mimeType == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return MimeTypeUtil.isSupportedImageType(mimeType) || (
|
||||
MimeTypeUtil.isSameMimeType(MimeTypeUtil.DEFAULT_ATTACHMENT_MIME_TYPE, mimeType) &&
|
||||
MimeTypeUtil.isSupportedImageExtension(displayName));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue