Repo created
This commit is contained in:
parent
75dc487a7a
commit
39c29d175b
6317 changed files with 388324 additions and 2 deletions
9
legacy/README.md
Normal file
9
legacy/README.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
## Legacy
|
||||
|
||||
The submodules within this module are considered legacy code that will still be maintained for the purpose of supporting the existing implementation. Over time, the legacy code will be replaced with new features and improvements and will be removed.
|
||||
|
||||
> [!WARNING]
|
||||
> It's not suggested to use the contained modules for new features!
|
||||
>
|
||||
> Please consider writing new features within the feature modules and migrate existing functionality there.
|
||||
|
||||
45
legacy/common/build.gradle.kts
Normal file
45
legacy/common/build.gradle.kts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.android)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.legacy.ui.legacy)
|
||||
implementation(projects.legacy.core)
|
||||
implementation(projects.legacy.storage)
|
||||
implementation(projects.legacy.cryptoOpenpgp)
|
||||
implementation(projects.backend.imap)
|
||||
implementation(projects.backend.pop3)
|
||||
|
||||
implementation(projects.core.featureflag)
|
||||
implementation(projects.core.logging.api)
|
||||
implementation(projects.feature.launcher)
|
||||
|
||||
implementation(projects.feature.account.setup)
|
||||
implementation(projects.feature.account.edit)
|
||||
implementation(projects.feature.navigation.drawer.api)
|
||||
implementation(projects.feature.settings.import)
|
||||
|
||||
implementation(projects.feature.widget.unread)
|
||||
implementation(projects.feature.widget.messageList)
|
||||
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.preferencex)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.appauth)
|
||||
|
||||
implementation(libs.glide)
|
||||
annotationProcessor(libs.glide.compiler)
|
||||
|
||||
if (project.hasProperty("k9mail.enableLeakCanary") && project.property("k9mail.enableLeakCanary") == "true") {
|
||||
debugImplementation(libs.leakcanary.android)
|
||||
}
|
||||
|
||||
testImplementation(projects.core.logging.testing)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(projects.feature.account.fake)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.fsck.k9.common"
|
||||
}
|
||||
342
legacy/common/src/main/AndroidManifest.xml
Normal file
342
legacy/common/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
>
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false"
|
||||
/>
|
||||
|
||||
<supports-screens
|
||||
android:anyDensity="true"
|
||||
android:largeScreens="true"
|
||||
android:normalScreens="true"
|
||||
android:smallScreens="true"
|
||||
/>
|
||||
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" android:maxSdkVersion="33" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"
|
||||
tools:ignore="ProtectedPermissions"
|
||||
/>
|
||||
|
||||
<application
|
||||
android:allowTaskReparenting="false"
|
||||
android:resizeableActivity="true"
|
||||
android:supportsRtl="true"
|
||||
tools:ignore="UnusedAttribute"
|
||||
>
|
||||
|
||||
<!-- TODO: Remove once minSdkVersion has been changed to 24+ -->
|
||||
<meta-data
|
||||
android:name="com.lge.support.SPLIT_WINDOW"
|
||||
android:value="true"
|
||||
/>
|
||||
<uses-library
|
||||
android:name="com.sec.android.app.multiwindow"
|
||||
android:required="false"
|
||||
/>
|
||||
|
||||
<meta-data
|
||||
android:name="com.sec.android.support.multiwindow"
|
||||
android:value="true"
|
||||
/>
|
||||
<meta-data
|
||||
android:name="com.samsung.android.sdk.multiwindow.penwindow.enable"
|
||||
android:value="true"
|
||||
/>
|
||||
<meta-data
|
||||
android:name="android.webkit.WebView.MetricsOptOut"
|
||||
android:value="true"
|
||||
/>
|
||||
|
||||
<activity
|
||||
android:name="com.fsck.k9.activity.setup.AccountSetupComposition"
|
||||
android:configChanges="locale"
|
||||
android:label="@string/account_settings_composition_title"
|
||||
/>
|
||||
|
||||
<activity
|
||||
android:name="com.fsck.k9.ui.choosefolder.ChooseFolderActivity"
|
||||
android:configChanges="locale"
|
||||
android:label="@string/choose_folder_title"
|
||||
android:noHistory="true"
|
||||
/>
|
||||
|
||||
<activity
|
||||
android:name="com.fsck.k9.activity.ChooseIdentity"
|
||||
android:configChanges="locale"
|
||||
android:label="@string/choose_identity_title"
|
||||
/>
|
||||
|
||||
<activity
|
||||
android:name="com.fsck.k9.activity.ManageIdentities"
|
||||
android:configChanges="locale"
|
||||
android:label="@string/manage_identities_title"
|
||||
/>
|
||||
|
||||
<activity
|
||||
android:name="com.fsck.k9.activity.EditIdentity"
|
||||
android:configChanges="locale"
|
||||
android:label="@string/edit_identity_title"
|
||||
/>
|
||||
|
||||
<activity
|
||||
android:name="com.fsck.k9.ui.endtoend.AutocryptKeyTransferActivity"
|
||||
android:configChanges="locale"
|
||||
android:label="@string/ac_transfer_title"
|
||||
/>
|
||||
|
||||
<activity
|
||||
android:name="com.fsck.k9.activity.MessageList"
|
||||
android:launchMode="singleTop"
|
||||
android:exported="true"
|
||||
>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.APP_EMAIL" />
|
||||
|
||||
<!-- TODO: Remove once minSdkVersion has been changed to 24+ -->
|
||||
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
|
||||
<category android:name="android.intent.category.PENWINDOW_LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data
|
||||
android:host="messages"
|
||||
android:scheme="k9mail"
|
||||
/>
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
This component is disabled by default. It will be enabled programmatically after an account has been set up.
|
||||
-->
|
||||
<activity
|
||||
android:name="com.fsck.k9.activity.MessageCompose"
|
||||
android:configChanges="locale"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SENDTO" />
|
||||
<data android:scheme="mailto" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
|
||||
<data android:scheme="mailto" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<data android:mimeType="*/*" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<data android:mimeType="*/*" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="mailto" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="org.autocrypt.PEER_ACTION" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name="com.fsck.k9.activity.Search"
|
||||
android:label="@string/search_action"
|
||||
android:exported="false"
|
||||
/>
|
||||
|
||||
<activity
|
||||
android:name="com.fsck.k9.activity.UpgradeDatabases"
|
||||
android:label="@string/upgrade_databases_title"
|
||||
/>
|
||||
|
||||
<activity
|
||||
android:name="com.fsck.k9.ui.managefolders.ManageFoldersActivity"
|
||||
android:label="@string/folders_action"
|
||||
/>
|
||||
|
||||
<activity
|
||||
android:name="com.fsck.k9.ui.settings.SettingsActivity"
|
||||
android:label="@string/prefs_title"
|
||||
/>
|
||||
|
||||
<activity
|
||||
android:name="com.fsck.k9.ui.settings.general.GeneralSettingsActivity"
|
||||
android:label="@string/general_settings_title"
|
||||
/>
|
||||
|
||||
<activity
|
||||
android:name="com.fsck.k9.ui.settings.account.AccountSettingsActivity"
|
||||
android:label="@string/account_settings_title_fmt"
|
||||
/>
|
||||
|
||||
<activity
|
||||
android:name="com.fsck.k9.ui.messagesource.MessageSourceActivity"
|
||||
android:label="@string/show_headers_action"
|
||||
/>
|
||||
|
||||
<activity
|
||||
android:name="com.fsck.k9.ui.changelog.RecentChangesActivity"
|
||||
android:label="@string/changelog_recent_changes_title"
|
||||
/>
|
||||
|
||||
<activity
|
||||
android:name="com.fsck.k9.ui.push.PushInfoActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:label="@string/push_info_title"
|
||||
android:taskAffinity="${applicationId}.push_info"
|
||||
>
|
||||
<intent-filter>
|
||||
<action android:name="app.k9mail.action.PUSH_INFO" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- This component is disabled by default. It will be enabled programmatically if necessary. -->
|
||||
<receiver
|
||||
android:name="com.fsck.k9.controller.push.BootCompleteReceiver"
|
||||
android:exported="false"
|
||||
android:enabled="false"
|
||||
>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<service android:name="com.fsck.k9.notification.NotificationActionService" />
|
||||
|
||||
<service
|
||||
android:name="com.fsck.k9.service.DatabaseUpgradeService"
|
||||
android:exported="false"
|
||||
/>
|
||||
|
||||
<service
|
||||
android:name="com.fsck.k9.controller.push.PushService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync|specialUse"
|
||||
>
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="This service is used to maintain a continuous connection to an IMAP server to be able to provide instant notifications to the user when a new email arrives. Firebase Cloud Messaging is not suitable for this task, neither are mechanisms like AndroidX WorkManager. Other foreground service types aren't a good fit for this use case."
|
||||
/>
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="com.fsck.k9.provider.AttachmentProvider"
|
||||
android:authorities="${applicationId}.attachmentprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true"
|
||||
>
|
||||
|
||||
<meta-data
|
||||
android:name="de.cketti.safecontentresolver.ALLOW_INTERNAL_ACCESS"
|
||||
android:value="true"
|
||||
/>
|
||||
|
||||
</provider>
|
||||
|
||||
<provider
|
||||
android:name="com.fsck.k9.provider.RawMessageProvider"
|
||||
android:authorities="${applicationId}.rawmessageprovider"
|
||||
android:exported="false"
|
||||
>
|
||||
|
||||
<meta-data
|
||||
android:name="de.cketti.safecontentresolver.ALLOW_INTERNAL_ACCESS"
|
||||
android:value="true"
|
||||
/>
|
||||
|
||||
</provider>
|
||||
|
||||
<provider
|
||||
android:name="com.fsck.k9.provider.DecryptedFileProvider"
|
||||
android:authorities="${applicationId}.decryptedfileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true"
|
||||
>
|
||||
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/decrypted_file_provider_paths"
|
||||
/>
|
||||
</provider>
|
||||
|
||||
<provider
|
||||
android:name="com.fsck.k9.provider.AttachmentTempFileProvider"
|
||||
android:authorities="${applicationId}.tempfileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true"
|
||||
>
|
||||
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/temp_file_provider_paths"
|
||||
/>
|
||||
|
||||
<meta-data
|
||||
android:name="de.cketti.safecontentresolver.ALLOW_INTERNAL_ACCESS"
|
||||
android:value="true"
|
||||
/>
|
||||
</provider>
|
||||
|
||||
<activity
|
||||
android:name="net.openid.appauth.RedirectUriReceiverActivity"
|
||||
android:exported="true"
|
||||
>
|
||||
|
||||
<!-- The library's default intent filter with `appAuthRedirectScheme` replaced by `applicationId` -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="${applicationId}" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Microsoft uses a special redirect URI format for Android apps -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:scheme="msauth"
|
||||
android:host="${applicationId}"
|
||||
/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import app.k9mail.feature.widget.message.list.messageListWidgetModule
|
||||
import app.k9mail.feature.widget.unread.UnreadWidgetUpdateListener
|
||||
import app.k9mail.feature.widget.unread.unreadWidgetModule
|
||||
import com.fsck.k9.account.newAccountModule
|
||||
import com.fsck.k9.backends.backendsModule
|
||||
import com.fsck.k9.controller.ControllerExtension
|
||||
import com.fsck.k9.crypto.EncryptionExtractor
|
||||
import com.fsck.k9.crypto.openpgp.OpenPgpEncryptionExtractor
|
||||
import com.fsck.k9.notification.notificationModule
|
||||
import com.fsck.k9.preferences.K9StoragePersister
|
||||
import com.fsck.k9.resources.resourcesModule
|
||||
import com.fsck.k9.storage.storageModule
|
||||
import net.thunderbird.core.featureflag.FeatureFlagProvider
|
||||
import net.thunderbird.core.featureflag.InMemoryFeatureFlagProvider
|
||||
import net.thunderbird.core.preference.storage.StoragePersister
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
|
||||
val legacyCommonAppModule = module {
|
||||
single<MessagingListenerProvider> {
|
||||
DefaultMessagingListenerProvider(
|
||||
listeners = listOf(
|
||||
get<UnreadWidgetUpdateListener>(),
|
||||
),
|
||||
)
|
||||
}
|
||||
single(named("controllerExtensions")) { emptyList<ControllerExtension>() }
|
||||
single<EncryptionExtractor> { OpenPgpEncryptionExtractor.newInstance() }
|
||||
single<StoragePersister> {
|
||||
K9StoragePersister(get(), get())
|
||||
}
|
||||
single<FeatureFlagProvider> {
|
||||
InMemoryFeatureFlagProvider(
|
||||
featureFlagFactory = get(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val legacyCommonAppModules = listOf(
|
||||
legacyCommonAppModule,
|
||||
messageListWidgetModule,
|
||||
unreadWidgetModule,
|
||||
notificationModule,
|
||||
resourcesModule,
|
||||
backendsModule,
|
||||
storageModule,
|
||||
newAccountModule,
|
||||
)
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.fsck.k9
|
||||
|
||||
import app.k9mail.legacy.message.controller.MessagingListener
|
||||
|
||||
interface MessagingListenerProvider {
|
||||
val listeners: List<MessagingListener>
|
||||
}
|
||||
|
||||
class DefaultMessagingListenerProvider(
|
||||
override val listeners: List<MessagingListener>,
|
||||
) : MessagingListenerProvider
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package com.fsck.k9.account
|
||||
|
||||
import android.content.Context
|
||||
import app.k9mail.feature.settings.import.SettingsImportExternalContract
|
||||
import com.fsck.k9.Core
|
||||
import com.fsck.k9.Preferences
|
||||
import com.fsck.k9.controller.MessagingController
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
|
||||
/**
|
||||
* Activate account after server password(s) have been provided on settings import.
|
||||
*/
|
||||
class AccountActivator(
|
||||
private val context: Context,
|
||||
private val preferences: Preferences,
|
||||
private val messagingController: MessagingController,
|
||||
) : SettingsImportExternalContract.AccountActivator {
|
||||
override fun enableAccount(accountUuid: String, incomingServerPassword: String?, outgoingServerPassword: String?) {
|
||||
val account = preferences.getAccount(accountUuid) ?: error("Account $accountUuid not found")
|
||||
|
||||
setAccountPasswords(account, incomingServerPassword, outgoingServerPassword)
|
||||
enableAccount(account)
|
||||
}
|
||||
|
||||
override fun enableAccount(accountUuid: String) {
|
||||
val account = preferences.getAccount(accountUuid) ?: error("Account $accountUuid not found")
|
||||
|
||||
enableAccount(account)
|
||||
}
|
||||
|
||||
private fun enableAccount(account: LegacyAccount) {
|
||||
// Start services if necessary
|
||||
Core.setServicesEnabled(context)
|
||||
|
||||
// Get list of folders from remote server
|
||||
messagingController.refreshFolderList(account)
|
||||
}
|
||||
|
||||
private fun setAccountPasswords(
|
||||
account: LegacyAccount,
|
||||
incomingServerPassword: String?,
|
||||
outgoingServerPassword: String?,
|
||||
) {
|
||||
if (incomingServerPassword != null) {
|
||||
account.incomingServerSettings = account.incomingServerSettings.newPassword(incomingServerPassword)
|
||||
}
|
||||
|
||||
if (outgoingServerPassword != null) {
|
||||
account.outgoingServerSettings = account.outgoingServerSettings.newPassword(outgoingServerPassword)
|
||||
}
|
||||
|
||||
preferences.saveAccount(account)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package com.fsck.k9.account
|
||||
|
||||
import app.k9mail.feature.account.common.AccountCommonExternalContract
|
||||
import app.k9mail.feature.account.edit.AccountEditExternalContract
|
||||
import app.k9mail.feature.account.setup.AccountSetupExternalContract
|
||||
import app.k9mail.feature.settings.import.SettingsImportExternalContract
|
||||
import org.koin.dsl.module
|
||||
|
||||
val newAccountModule = module {
|
||||
factory<AccountSetupExternalContract.AccountOwnerNameProvider> {
|
||||
AccountOwnerNameProvider(
|
||||
preferences = get(),
|
||||
)
|
||||
}
|
||||
|
||||
factory<AccountCommonExternalContract.AccountStateLoader> {
|
||||
AccountStateLoader(
|
||||
accountManager = get(),
|
||||
)
|
||||
}
|
||||
|
||||
factory<AccountEditExternalContract.AccountServerSettingsUpdater> {
|
||||
AccountServerSettingsUpdater(
|
||||
accountManager = get(),
|
||||
)
|
||||
}
|
||||
|
||||
factory<SettingsImportExternalContract.AccountActivator> {
|
||||
AccountActivator(
|
||||
context = get(),
|
||||
preferences = get(),
|
||||
messagingController = get(),
|
||||
)
|
||||
}
|
||||
|
||||
factory<DeletePolicyProvider> { DefaultDeletePolicyProvider() }
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.fsck.k9.account
|
||||
|
||||
import app.k9mail.feature.account.setup.AccountSetupExternalContract
|
||||
import com.fsck.k9.Preferences
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class AccountOwnerNameProvider(
|
||||
private val preferences: Preferences,
|
||||
private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) : AccountSetupExternalContract.AccountOwnerNameProvider {
|
||||
override suspend fun getOwnerName(): String? {
|
||||
return withContext(coroutineDispatcher) {
|
||||
preferences.defaultAccount?.senderName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package com.fsck.k9.account
|
||||
|
||||
import app.k9mail.feature.account.common.domain.entity.AuthorizationState
|
||||
import app.k9mail.feature.account.edit.AccountEditExternalContract
|
||||
import app.k9mail.feature.account.edit.AccountEditExternalContract.AccountUpdaterFailure
|
||||
import app.k9mail.feature.account.edit.AccountEditExternalContract.AccountUpdaterResult
|
||||
import com.fsck.k9.mail.ServerSettings
|
||||
import com.fsck.k9.mail.store.imap.ImapStoreSettings
|
||||
import com.fsck.k9.mail.store.imap.ImapStoreSettings.autoDetectNamespace
|
||||
import com.fsck.k9.mail.store.imap.ImapStoreSettings.isSendClientInfo
|
||||
import com.fsck.k9.mail.store.imap.ImapStoreSettings.isUseCompression
|
||||
import com.fsck.k9.mail.store.imap.ImapStoreSettings.pathPrefix
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.thunderbird.core.android.account.AccountManager
|
||||
import net.thunderbird.core.common.mail.Protocols
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
|
||||
class AccountServerSettingsUpdater(
|
||||
private val accountManager: AccountManager,
|
||||
private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) : AccountEditExternalContract.AccountServerSettingsUpdater {
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override suspend fun updateServerSettings(
|
||||
accountUuid: String,
|
||||
isIncoming: Boolean,
|
||||
serverSettings: ServerSettings,
|
||||
authorizationState: AuthorizationState?,
|
||||
): AccountUpdaterResult {
|
||||
return try {
|
||||
withContext(coroutineDispatcher) {
|
||||
updateSettings(accountUuid, isIncoming, serverSettings, authorizationState)
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
Log.e(error, "Error while updating account server settings with UUID %s", accountUuid)
|
||||
|
||||
AccountUpdaterResult.Failure(AccountUpdaterFailure.UnknownError(error))
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSettings(
|
||||
accountUuid: String,
|
||||
isIncoming: Boolean,
|
||||
serverSettings: ServerSettings,
|
||||
authorizationState: AuthorizationState?,
|
||||
): AccountUpdaterResult {
|
||||
val account = accountManager.getAccount(accountUuid = accountUuid) ?: return AccountUpdaterResult.Failure(
|
||||
AccountUpdaterFailure.AccountNotFound(accountUuid),
|
||||
)
|
||||
|
||||
if (isIncoming) {
|
||||
if (serverSettings.type == Protocols.IMAP) {
|
||||
account.useCompression = serverSettings.isUseCompression
|
||||
account.isSendClientInfoEnabled = serverSettings.isSendClientInfo
|
||||
account.incomingServerSettings = serverSettings.copy(
|
||||
extra = ImapStoreSettings.createExtra(
|
||||
autoDetectNamespace = serverSettings.autoDetectNamespace,
|
||||
pathPrefix = serverSettings.pathPrefix,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
account.incomingServerSettings = serverSettings
|
||||
}
|
||||
} else {
|
||||
account.outgoingServerSettings = serverSettings
|
||||
}
|
||||
|
||||
account.oAuthState = authorizationState?.value
|
||||
|
||||
accountManager.saveAccount(account)
|
||||
|
||||
return AccountUpdaterResult.Success(accountUuid)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package com.fsck.k9.account
|
||||
|
||||
import app.k9mail.feature.account.common.AccountCommonExternalContract
|
||||
import app.k9mail.feature.account.common.domain.entity.AccountState
|
||||
import app.k9mail.feature.account.common.domain.entity.AuthorizationState
|
||||
import com.fsck.k9.backends.toImapServerSettings
|
||||
import com.fsck.k9.mail.ServerSettings
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.thunderbird.core.android.account.AccountManager
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
import net.thunderbird.core.common.mail.Protocols
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
|
||||
class AccountStateLoader(
|
||||
private val accountManager: AccountManager,
|
||||
private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) : AccountCommonExternalContract.AccountStateLoader {
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override suspend fun loadAccountState(accountUuid: String): AccountState? {
|
||||
return try {
|
||||
withContext(coroutineDispatcher) {
|
||||
load(accountUuid)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(e, "Error while loading account")
|
||||
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun load(accountUuid: String): AccountState? {
|
||||
return accountManager.getAccount(accountUuid)?.let { mapToAccountState(it) }
|
||||
}
|
||||
|
||||
private fun mapToAccountState(account: LegacyAccount): AccountState {
|
||||
return AccountState(
|
||||
uuid = account.uuid,
|
||||
emailAddress = account.email,
|
||||
incomingServerSettings = account.incomingServerSettingsExtra,
|
||||
outgoingServerSettings = account.outgoingServerSettings,
|
||||
authorizationState = AuthorizationState(account.oAuthState),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val LegacyAccount.incomingServerSettingsExtra: ServerSettings
|
||||
get() = when (incomingServerSettings.type) {
|
||||
Protocols.IMAP -> toImapServerSettings()
|
||||
else -> incomingServerSettings
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.fsck.k9.account
|
||||
|
||||
import net.thunderbird.core.android.account.DeletePolicy
|
||||
import net.thunderbird.core.common.mail.Protocols
|
||||
|
||||
class DefaultDeletePolicyProvider : DeletePolicyProvider {
|
||||
override fun getDeletePolicy(accountType: String): DeletePolicy {
|
||||
return when (accountType) {
|
||||
Protocols.IMAP -> DeletePolicy.ON_DELETE
|
||||
Protocols.POP3 -> DeletePolicy.NEVER
|
||||
"demo" -> DeletePolicy.ON_DELETE
|
||||
else -> throw AssertionError("Unhandled case: $accountType")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.fsck.k9.account
|
||||
|
||||
import net.thunderbird.core.android.account.DeletePolicy
|
||||
import net.thunderbird.core.common.mail.Protocols
|
||||
|
||||
/**
|
||||
* Decides which [DeletePolicy] an account uses by default.
|
||||
*/
|
||||
interface DeletePolicyProvider {
|
||||
/**
|
||||
* Returns the [DeletePolicy] an account of type [accountType] should use by default.
|
||||
*
|
||||
* @param accountType The protocol identifier of the incoming server of an account. See [Protocols].
|
||||
*/
|
||||
fun getDeletePolicy(accountType: String): DeletePolicy
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.fsck.k9.backends
|
||||
|
||||
import com.fsck.k9.mail.oauth.AuthStateStorage
|
||||
import net.thunderbird.core.android.account.AccountManager
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
|
||||
class AccountAuthStateStorage(
|
||||
private val accountManager: AccountManager,
|
||||
private val account: LegacyAccount,
|
||||
) : AuthStateStorage {
|
||||
override fun getAuthorizationState(): String? {
|
||||
return account.oAuthState
|
||||
}
|
||||
|
||||
override fun updateAuthorizationState(authorizationState: String?) {
|
||||
account.oAuthState = authorizationState
|
||||
accountManager.saveAccount(account)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
package com.fsck.k9.backends
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.SystemClock
|
||||
import androidx.core.app.AlarmManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.fsck.k9.backend.imap.SystemAlarmManager
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
|
||||
private const val ALARM_ACTION = "com.fsck.k9.backends.ALARM"
|
||||
private const val REQUEST_CODE = 1
|
||||
|
||||
private typealias Callback = () -> Unit
|
||||
|
||||
class AndroidAlarmManager(
|
||||
private val context: Context,
|
||||
private val alarmManager: AlarmManager,
|
||||
backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) : SystemAlarmManager {
|
||||
private val coroutineScope = CoroutineScope(backgroundDispatcher)
|
||||
|
||||
private val pendingIntent: PendingIntent = run {
|
||||
val intent = Intent(ALARM_ACTION).apply {
|
||||
setPackage(context.packageName)
|
||||
}
|
||||
|
||||
PendingIntentCompat.getBroadcast(context, REQUEST_CODE, intent, 0, false)!!
|
||||
}
|
||||
|
||||
private val callback = AtomicReference<Callback?>(null)
|
||||
|
||||
init {
|
||||
val intentFilter = IntentFilter(ALARM_ACTION)
|
||||
ContextCompat.registerReceiver(
|
||||
context,
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val callback = callback.getAndSet(null)
|
||||
if (callback == null) {
|
||||
Log.w("Alarm triggered but 'callback' was null")
|
||||
} else {
|
||||
coroutineScope.launch {
|
||||
callback.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
intentFilter,
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
}
|
||||
|
||||
override fun setAlarm(triggerTime: Long, callback: Callback) {
|
||||
this.callback.set(callback)
|
||||
|
||||
AlarmManagerCompat.setExactAndAllowWhileIdle(
|
||||
alarmManager,
|
||||
AlarmManager.ELAPSED_REALTIME_WAKEUP,
|
||||
triggerTime,
|
||||
pendingIntent,
|
||||
)
|
||||
}
|
||||
|
||||
override fun cancelAlarm() {
|
||||
callback.set(null)
|
||||
alarmManager.cancel(pendingIntent)
|
||||
}
|
||||
|
||||
override fun now(): Long = SystemClock.elapsedRealtime()
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
package com.fsck.k9.backends
|
||||
|
||||
import android.content.Context
|
||||
import com.fsck.k9.backend.BackendFactory
|
||||
import com.fsck.k9.backend.api.Backend
|
||||
import com.fsck.k9.backend.imap.ImapBackend
|
||||
import com.fsck.k9.backend.imap.ImapPushConfigProvider
|
||||
import com.fsck.k9.mail.AuthType
|
||||
import com.fsck.k9.mail.power.PowerManager
|
||||
import com.fsck.k9.mail.ssl.TrustedSocketFactory
|
||||
import com.fsck.k9.mail.store.imap.IdleRefreshManager
|
||||
import com.fsck.k9.mail.store.imap.ImapClientInfo
|
||||
import com.fsck.k9.mail.store.imap.ImapStore
|
||||
import com.fsck.k9.mail.store.imap.ImapStoreConfig
|
||||
import com.fsck.k9.mail.transport.smtp.SmtpTransport
|
||||
import com.fsck.k9.mailstore.K9BackendStorageFactory
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import net.thunderbird.core.android.account.AccountManager
|
||||
import net.thunderbird.core.android.account.Expunge
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
class ImapBackendFactory(
|
||||
private val accountManager: AccountManager,
|
||||
private val powerManager: PowerManager,
|
||||
private val idleRefreshManager: IdleRefreshManager,
|
||||
private val backendStorageFactory: K9BackendStorageFactory,
|
||||
private val trustedSocketFactory: TrustedSocketFactory,
|
||||
private val context: Context,
|
||||
private val clientInfoAppName: String,
|
||||
private val clientInfoAppVersion: String,
|
||||
) : BackendFactory {
|
||||
override fun createBackend(account: LegacyAccount): Backend {
|
||||
val accountName = account.displayName
|
||||
val backendStorage = backendStorageFactory.createBackendStorage(account)
|
||||
val imapStore = createImapStore(account)
|
||||
val pushConfigProvider = createPushConfigProvider(account)
|
||||
val smtpTransport = createSmtpTransport(account)
|
||||
|
||||
return ImapBackend(
|
||||
accountName,
|
||||
backendStorage,
|
||||
imapStore,
|
||||
powerManager,
|
||||
idleRefreshManager,
|
||||
pushConfigProvider,
|
||||
smtpTransport,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createImapStore(account: LegacyAccount): ImapStore {
|
||||
val serverSettings = account.toImapServerSettings()
|
||||
|
||||
val oAuth2TokenProvider = if (serverSettings.authenticationType == AuthType.XOAUTH2) {
|
||||
createOAuth2TokenProvider(account)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val config = createImapStoreConfig(account)
|
||||
return ImapStore.create(
|
||||
serverSettings,
|
||||
config,
|
||||
trustedSocketFactory,
|
||||
oAuth2TokenProvider,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createImapStoreConfig(account: LegacyAccount): ImapStoreConfig {
|
||||
return object : ImapStoreConfig {
|
||||
override val logLabel
|
||||
get() = account.uuid
|
||||
|
||||
override fun isSubscribedFoldersOnly() = account.isSubscribedFoldersOnly
|
||||
|
||||
override fun isExpungeImmediately() = account.expungePolicy == Expunge.EXPUNGE_IMMEDIATELY
|
||||
|
||||
override fun clientInfo() = ImapClientInfo(appName = clientInfoAppName, appVersion = clientInfoAppVersion)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSmtpTransport(account: LegacyAccount): SmtpTransport {
|
||||
val serverSettings = account.outgoingServerSettings
|
||||
val oauth2TokenProvider = if (serverSettings.authenticationType == AuthType.XOAUTH2) {
|
||||
createOAuth2TokenProvider(account)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
return SmtpTransport(serverSettings, trustedSocketFactory, oauth2TokenProvider)
|
||||
}
|
||||
|
||||
private fun createOAuth2TokenProvider(account: LegacyAccount): RealOAuth2TokenProvider {
|
||||
val authStateStorage = AccountAuthStateStorage(accountManager, account)
|
||||
return RealOAuth2TokenProvider(context, authStateStorage)
|
||||
}
|
||||
|
||||
private fun createPushConfigProvider(account: LegacyAccount) = object : ImapPushConfigProvider {
|
||||
override val maxPushFoldersFlow: Flow<Int>
|
||||
get() = accountManager.getAccountFlow(account.uuid)
|
||||
.filterNotNull()
|
||||
.map { it.maxPushFolders }
|
||||
.distinctUntilChanged()
|
||||
|
||||
override val idleRefreshMinutesFlow: Flow<Int>
|
||||
get() = accountManager.getAccountFlow(account.uuid)
|
||||
.filterNotNull()
|
||||
.map { it.idleRefreshMinutes }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.fsck.k9.backends
|
||||
|
||||
import com.fsck.k9.mail.ServerSettings
|
||||
import com.fsck.k9.mail.store.imap.ImapStoreSettings
|
||||
import com.fsck.k9.mail.store.imap.ImapStoreSettings.autoDetectNamespace
|
||||
import com.fsck.k9.mail.store.imap.ImapStoreSettings.pathPrefix
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
|
||||
fun LegacyAccount.toImapServerSettings(): ServerSettings {
|
||||
val serverSettings = incomingServerSettings
|
||||
return serverSettings.copy(
|
||||
extra = ImapStoreSettings.createExtra(
|
||||
autoDetectNamespace = serverSettings.autoDetectNamespace,
|
||||
pathPrefix = serverSettings.pathPrefix,
|
||||
useCompression = useCompression,
|
||||
sendClientInfo = isSendClientInfoEnabled,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package com.fsck.k9.backends
|
||||
|
||||
import com.fsck.k9.backend.BackendManager
|
||||
import com.fsck.k9.backend.imap.BackendIdleRefreshManager
|
||||
import com.fsck.k9.backend.imap.SystemAlarmManager
|
||||
import com.fsck.k9.mail.oauth.OAuth2TokenProviderFactory
|
||||
import com.fsck.k9.mail.store.imap.IdleRefreshManager
|
||||
import net.thunderbird.backend.api.BackendFactory
|
||||
import net.thunderbird.backend.api.folder.RemoteFolderCreator
|
||||
import net.thunderbird.backend.imap.ImapRemoteFolderCreatorFactory
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
import com.fsck.k9.backend.BackendFactory as LegacyBackendFactory
|
||||
|
||||
val backendsModule = module {
|
||||
single {
|
||||
val developmentBackends = get<Map<String, LegacyBackendFactory>>(named("developmentBackends"))
|
||||
BackendManager(
|
||||
mapOf(
|
||||
"imap" to get<ImapBackendFactory>(),
|
||||
"pop3" to get<Pop3BackendFactory>(),
|
||||
) + developmentBackends,
|
||||
)
|
||||
}
|
||||
single {
|
||||
ImapBackendFactory(
|
||||
accountManager = get(),
|
||||
powerManager = get(),
|
||||
idleRefreshManager = get(),
|
||||
backendStorageFactory = get(),
|
||||
trustedSocketFactory = get(),
|
||||
context = get(),
|
||||
clientInfoAppName = get(named("ClientInfoAppName")),
|
||||
clientInfoAppVersion = get(named("ClientInfoAppVersion")),
|
||||
)
|
||||
}
|
||||
|
||||
single<BackendFactory<*>>(named("imap")) {
|
||||
get<ImapBackendFactory>()
|
||||
}
|
||||
|
||||
single<RemoteFolderCreator.Factory>(named("imap")) {
|
||||
ImapRemoteFolderCreatorFactory(
|
||||
logger = get(),
|
||||
backendFactory = get(named("imap")),
|
||||
)
|
||||
}
|
||||
|
||||
single<SystemAlarmManager> { AndroidAlarmManager(context = get(), alarmManager = get()) }
|
||||
single<IdleRefreshManager> { BackendIdleRefreshManager(alarmManager = get()) }
|
||||
single { Pop3BackendFactory(get(), get()) }
|
||||
single<OAuth2TokenProviderFactory> { RealOAuth2TokenProviderFactory(context = get()) }
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package com.fsck.k9.backends
|
||||
|
||||
import com.fsck.k9.backend.BackendFactory
|
||||
import com.fsck.k9.backend.api.Backend
|
||||
import com.fsck.k9.backend.pop3.Pop3Backend
|
||||
import com.fsck.k9.mail.oauth.OAuth2TokenProvider
|
||||
import com.fsck.k9.mail.ssl.TrustedSocketFactory
|
||||
import com.fsck.k9.mail.store.pop3.Pop3Store
|
||||
import com.fsck.k9.mail.transport.smtp.SmtpTransport
|
||||
import com.fsck.k9.mailstore.K9BackendStorageFactory
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
|
||||
class Pop3BackendFactory(
|
||||
private val backendStorageFactory: K9BackendStorageFactory,
|
||||
private val trustedSocketFactory: TrustedSocketFactory,
|
||||
) : BackendFactory {
|
||||
override fun createBackend(account: LegacyAccount): Backend {
|
||||
val accountName = account.displayName
|
||||
val backendStorage = backendStorageFactory.createBackendStorage(account)
|
||||
val pop3Store = createPop3Store(account)
|
||||
val smtpTransport = createSmtpTransport(account)
|
||||
return Pop3Backend(accountName, backendStorage, pop3Store, smtpTransport)
|
||||
}
|
||||
|
||||
private fun createPop3Store(account: LegacyAccount): Pop3Store {
|
||||
val serverSettings = account.incomingServerSettings
|
||||
return Pop3Store(serverSettings, trustedSocketFactory)
|
||||
}
|
||||
|
||||
private fun createSmtpTransport(account: LegacyAccount): SmtpTransport {
|
||||
val serverSettings = account.outgoingServerSettings
|
||||
val oauth2TokenProvider: OAuth2TokenProvider? = null
|
||||
return SmtpTransport(serverSettings, trustedSocketFactory, oauth2TokenProvider)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
package com.fsck.k9.backends
|
||||
|
||||
import android.content.Context
|
||||
import com.fsck.k9.mail.AuthenticationFailedException
|
||||
import com.fsck.k9.mail.oauth.AuthStateStorage
|
||||
import com.fsck.k9.mail.oauth.OAuth2TokenProvider
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import net.openid.appauth.AuthState
|
||||
import net.openid.appauth.AuthorizationException
|
||||
import net.openid.appauth.AuthorizationException.AuthorizationRequestErrors
|
||||
import net.openid.appauth.AuthorizationException.GeneralErrors
|
||||
import net.openid.appauth.AuthorizationService
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
|
||||
class RealOAuth2TokenProvider(
|
||||
context: Context,
|
||||
private val authStateStorage: AuthStateStorage,
|
||||
) : OAuth2TokenProvider {
|
||||
private val authService = AuthorizationService(context)
|
||||
private var requestFreshToken = false
|
||||
|
||||
override val primaryEmail: String?
|
||||
get() {
|
||||
return parseAuthState()
|
||||
.parsedIdToken
|
||||
?.additionalClaims
|
||||
?.get("email")
|
||||
?.toString()
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override fun getToken(timeoutMillis: Long): String {
|
||||
val latch = CountDownLatch(1)
|
||||
var token: String? = null
|
||||
var exception: AuthorizationException? = null
|
||||
|
||||
val authState = parseAuthState()
|
||||
if (requestFreshToken) {
|
||||
authState.needsTokenRefresh = true
|
||||
}
|
||||
|
||||
val oldAccessToken = authState.accessToken
|
||||
|
||||
try {
|
||||
authState.performActionWithFreshTokens(
|
||||
authService,
|
||||
) { accessToken: String?, _, authException: AuthorizationException? ->
|
||||
token = accessToken
|
||||
exception = authException
|
||||
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
latch.await(timeoutMillis, TimeUnit.MILLISECONDS)
|
||||
} catch (e: Exception) {
|
||||
Log.w(e, "Failed to fetch an access token. Clearing authorization state.")
|
||||
|
||||
authStateStorage.updateAuthorizationState(authorizationState = null)
|
||||
|
||||
throw AuthenticationFailedException(
|
||||
message = "Failed to fetch an access token",
|
||||
throwable = e,
|
||||
)
|
||||
}
|
||||
|
||||
val authException = exception
|
||||
if (authException == GeneralErrors.NETWORK_ERROR ||
|
||||
authException == GeneralErrors.SERVER_ERROR ||
|
||||
authException == AuthorizationRequestErrors.SERVER_ERROR ||
|
||||
authException == AuthorizationRequestErrors.TEMPORARILY_UNAVAILABLE
|
||||
) {
|
||||
throw IOException("Error while fetching an access token", authException)
|
||||
} else if (authException != null) {
|
||||
authStateStorage.updateAuthorizationState(authorizationState = null)
|
||||
|
||||
throw AuthenticationFailedException(
|
||||
message = "Failed to fetch an access token",
|
||||
throwable = authException,
|
||||
messageFromServer = authException.error,
|
||||
)
|
||||
} else if (token != oldAccessToken) {
|
||||
requestFreshToken = false
|
||||
authStateStorage.updateAuthorizationState(authorizationState = authState.jsonSerializeString())
|
||||
}
|
||||
|
||||
return token ?: throw AuthenticationFailedException("Failed to fetch an access token")
|
||||
}
|
||||
|
||||
override fun invalidateToken() {
|
||||
requestFreshToken = true
|
||||
}
|
||||
|
||||
private fun parseAuthState(): AuthState {
|
||||
return authStateStorage
|
||||
.getAuthorizationState()
|
||||
?.let { AuthState.jsonDeserialize(it) }
|
||||
?: throw AuthenticationFailedException("Login required")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.fsck.k9.backends
|
||||
|
||||
import android.content.Context
|
||||
import com.fsck.k9.mail.oauth.AuthStateStorage
|
||||
import com.fsck.k9.mail.oauth.OAuth2TokenProvider
|
||||
import com.fsck.k9.mail.oauth.OAuth2TokenProviderFactory
|
||||
|
||||
class RealOAuth2TokenProviderFactory(
|
||||
private val context: Context,
|
||||
) : OAuth2TokenProviderFactory {
|
||||
override fun create(authStateStorage: AuthStateStorage): OAuth2TokenProvider {
|
||||
return RealOAuth2TokenProvider(context, authStateStorage)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.fsck.k9.glide;
|
||||
|
||||
import com.bumptech.glide.annotation.GlideModule;
|
||||
import com.bumptech.glide.module.AppGlideModule;
|
||||
|
||||
@GlideModule
|
||||
public class K9AppGlideModule extends AppGlideModule {
|
||||
}
|
||||
|
|
@ -0,0 +1,280 @@
|
|||
package com.fsck.k9.notification
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.app.PendingIntent.FLAG_CANCEL_CURRENT
|
||||
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import app.k9mail.feature.launcher.FeatureLauncherActivity
|
||||
import app.k9mail.feature.launcher.FeatureLauncherTarget
|
||||
import app.k9mail.legacy.mailstore.MessageStoreManager
|
||||
import app.k9mail.legacy.message.controller.MessageReference
|
||||
import com.fsck.k9.K9
|
||||
import com.fsck.k9.activity.MessageList
|
||||
import com.fsck.k9.activity.compose.MessageActions
|
||||
import com.fsck.k9.ui.messagelist.DefaultFolderProvider
|
||||
import com.fsck.k9.ui.notification.DeleteConfirmationActivity
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
import net.thunderbird.core.preference.GeneralSettingsManager
|
||||
import net.thunderbird.feature.search.legacy.LocalMessageSearch
|
||||
|
||||
/**
|
||||
* This class contains methods to create the [PendingIntent]s for the actions of our notifications.
|
||||
*
|
||||
* **Note:**
|
||||
* We need to take special care to ensure the `PendingIntent`s are unique as defined in the documentation of
|
||||
* [PendingIntent]. Otherwise selecting a notification action might perform the action on the wrong message.
|
||||
*
|
||||
* We add unique values to `Intent.data` so we end up with unique `PendingIntent`s.
|
||||
*
|
||||
* In the past we've used the notification ID as `requestCode` argument when creating a `PendingIntent`. But since we're
|
||||
* reusing notification IDs, it's safer to make sure the `Intent` itself is unique.
|
||||
*/
|
||||
internal class K9NotificationActionCreator(
|
||||
private val context: Context,
|
||||
private val defaultFolderProvider: DefaultFolderProvider,
|
||||
private val messageStoreManager: MessageStoreManager,
|
||||
private val generalSettingsManager: GeneralSettingsManager,
|
||||
) : NotificationActionCreator {
|
||||
|
||||
override fun createViewMessagePendingIntent(messageReference: MessageReference): PendingIntent {
|
||||
val openInUnifiedInbox =
|
||||
generalSettingsManager.getConfig().display.inboxSettings.isShowUnifiedInbox &&
|
||||
isIncludedInUnifiedInbox(messageReference)
|
||||
val intent = createMessageViewIntent(messageReference, openInUnifiedInbox)
|
||||
|
||||
return PendingIntentCompat.getActivity(context, 0, intent, FLAG_UPDATE_CURRENT, false)!!
|
||||
}
|
||||
|
||||
override fun createViewFolderPendingIntent(account: LegacyAccount, folderId: Long): PendingIntent {
|
||||
val intent = createMessageListIntent(account, folderId)
|
||||
return PendingIntentCompat.getActivity(context, 0, intent, FLAG_UPDATE_CURRENT, false)!!
|
||||
}
|
||||
|
||||
override fun createViewMessagesPendingIntent(
|
||||
account: LegacyAccount,
|
||||
messageReferences: List<MessageReference>,
|
||||
): PendingIntent {
|
||||
val folderIds = extractFolderIds(messageReferences)
|
||||
|
||||
val intent = if (generalSettingsManager.getConfig()
|
||||
.display
|
||||
.inboxSettings
|
||||
.isShowUnifiedInbox &&
|
||||
areAllIncludedInUnifiedInbox(account, folderIds)
|
||||
) {
|
||||
createUnifiedInboxIntent(account)
|
||||
} else if (folderIds.size == 1) {
|
||||
createMessageListIntent(account, folderIds.first())
|
||||
} else {
|
||||
createNewMessagesIntent(account)
|
||||
}
|
||||
return PendingIntentCompat.getActivity(context, 0, intent, FLAG_UPDATE_CURRENT, false)!!
|
||||
}
|
||||
|
||||
override fun createViewFolderListPendingIntent(account: LegacyAccount): PendingIntent {
|
||||
val intent = createMessageListIntent(account)
|
||||
return PendingIntentCompat.getActivity(context, 0, intent, FLAG_UPDATE_CURRENT, false)!!
|
||||
}
|
||||
|
||||
override fun createDismissAllMessagesPendingIntent(account: LegacyAccount): PendingIntent {
|
||||
val intent = NotificationActionService.createDismissAllMessagesIntent(context, account).apply {
|
||||
data = Uri.parse("data:,dismissAll/${account.uuid}/${System.currentTimeMillis()}")
|
||||
}
|
||||
return PendingIntentCompat.getService(context, 0, intent, FLAG_UPDATE_CURRENT, false)!!
|
||||
}
|
||||
|
||||
override fun createDismissMessagePendingIntent(messageReference: MessageReference): PendingIntent {
|
||||
val intent = NotificationActionService.createDismissMessageIntent(context, messageReference).apply {
|
||||
data = Uri.parse("data:,dismiss/${messageReference.toIdentityString()}")
|
||||
}
|
||||
return PendingIntentCompat.getService(context, 0, intent, FLAG_UPDATE_CURRENT, false)!!
|
||||
}
|
||||
|
||||
override fun createReplyPendingIntent(messageReference: MessageReference): PendingIntent {
|
||||
val intent = MessageActions.getActionReplyIntent(context, messageReference).apply {
|
||||
data = Uri.parse("data:,reply/${messageReference.toIdentityString()}")
|
||||
}
|
||||
return PendingIntentCompat.getActivity(context, 0, intent, FLAG_UPDATE_CURRENT, false)!!
|
||||
}
|
||||
|
||||
override fun createMarkMessageAsReadPendingIntent(messageReference: MessageReference): PendingIntent {
|
||||
val intent = NotificationActionService.createMarkMessageAsReadIntent(context, messageReference).apply {
|
||||
data = Uri.parse("data:,markAsRead/${messageReference.toIdentityString()}")
|
||||
}
|
||||
return PendingIntentCompat.getService(context, 0, intent, FLAG_UPDATE_CURRENT, false)!!
|
||||
}
|
||||
|
||||
override fun createMarkAllAsReadPendingIntent(
|
||||
account: LegacyAccount,
|
||||
messageReferences: List<MessageReference>,
|
||||
): PendingIntent {
|
||||
val accountUuid = account.uuid
|
||||
val intent =
|
||||
NotificationActionService.createMarkAllAsReadIntent(context, accountUuid, messageReferences).apply {
|
||||
data = Uri.parse("data:,markAllAsRead/$accountUuid/${System.currentTimeMillis()}")
|
||||
}
|
||||
return PendingIntentCompat.getService(context, 0, intent, FLAG_UPDATE_CURRENT, false)!!
|
||||
}
|
||||
|
||||
override fun getEditIncomingServerSettingsIntent(account: LegacyAccount): PendingIntent {
|
||||
val intent = FeatureLauncherActivity.getIntent(
|
||||
context = context,
|
||||
target = FeatureLauncherTarget.AccountEditIncomingSettings(account.uuid),
|
||||
)
|
||||
return PendingIntentCompat.getActivity(context, account.accountNumber, intent, FLAG_UPDATE_CURRENT, false)!!
|
||||
}
|
||||
|
||||
override fun getEditOutgoingServerSettingsIntent(account: LegacyAccount): PendingIntent {
|
||||
val intent = FeatureLauncherActivity.getIntent(
|
||||
context = context,
|
||||
target = FeatureLauncherTarget.AccountEditOutgoingSettings(account.uuid),
|
||||
)
|
||||
return PendingIntentCompat.getActivity(context, account.accountNumber, intent, FLAG_UPDATE_CURRENT, false)!!
|
||||
}
|
||||
|
||||
override fun createDeleteMessagePendingIntent(messageReference: MessageReference): PendingIntent {
|
||||
return if (K9.isConfirmDeleteFromNotification) {
|
||||
createDeleteConfirmationPendingIntent(messageReference)
|
||||
} else {
|
||||
createDeleteServicePendingIntent(messageReference)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDeleteServicePendingIntent(messageReference: MessageReference): PendingIntent {
|
||||
val intent = NotificationActionService.createDeleteMessageIntent(context, messageReference).apply {
|
||||
data = Uri.parse("data:,delete/${messageReference.toIdentityString()}")
|
||||
}
|
||||
return PendingIntentCompat.getService(context, 0, intent, FLAG_UPDATE_CURRENT, false)!!
|
||||
}
|
||||
|
||||
private fun createDeleteConfirmationPendingIntent(messageReference: MessageReference): PendingIntent {
|
||||
val intent = DeleteConfirmationActivity.getIntent(context, messageReference).apply {
|
||||
data = Uri.parse("data:,deleteConfirmation/${messageReference.toIdentityString()}")
|
||||
}
|
||||
return PendingIntentCompat.getActivity(context, 0, intent, FLAG_UPDATE_CURRENT, false)!!
|
||||
}
|
||||
|
||||
override fun createDeleteAllPendingIntent(
|
||||
account: LegacyAccount,
|
||||
messageReferences: List<MessageReference>,
|
||||
): PendingIntent {
|
||||
return if (K9.isConfirmDeleteFromNotification) {
|
||||
getDeleteAllConfirmationPendingIntent(messageReferences)
|
||||
} else {
|
||||
getDeleteAllServicePendingIntent(account, messageReferences)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDeleteAllConfirmationPendingIntent(messageReferences: List<MessageReference>): PendingIntent {
|
||||
val intent = DeleteConfirmationActivity.getIntent(context, messageReferences).apply {
|
||||
data = Uri.parse("data:,deleteAllConfirmation/${System.currentTimeMillis()}")
|
||||
}
|
||||
return PendingIntentCompat.getActivity(context, 0, intent, FLAG_CANCEL_CURRENT, false)!!
|
||||
}
|
||||
|
||||
private fun getDeleteAllServicePendingIntent(
|
||||
account: LegacyAccount,
|
||||
messageReferences: List<MessageReference>,
|
||||
): PendingIntent {
|
||||
val accountUuid = account.uuid
|
||||
val intent =
|
||||
NotificationActionService.createDeleteAllMessagesIntent(context, accountUuid, messageReferences).apply {
|
||||
data = Uri.parse("data:,deleteAll/$accountUuid/${System.currentTimeMillis()}")
|
||||
}
|
||||
return PendingIntentCompat.getService(context, 0, intent, FLAG_UPDATE_CURRENT, false)!!
|
||||
}
|
||||
|
||||
override fun createArchiveMessagePendingIntent(messageReference: MessageReference): PendingIntent {
|
||||
val intent = NotificationActionService.createArchiveMessageIntent(context, messageReference).apply {
|
||||
data = Uri.parse("data:,archive/${messageReference.toIdentityString()}")
|
||||
}
|
||||
return PendingIntentCompat.getService(context, 0, intent, FLAG_UPDATE_CURRENT, false)!!
|
||||
}
|
||||
|
||||
override fun createArchiveAllPendingIntent(
|
||||
account: LegacyAccount,
|
||||
messageReferences: List<MessageReference>,
|
||||
): PendingIntent {
|
||||
val intent = NotificationActionService.createArchiveAllIntent(context, account, messageReferences).apply {
|
||||
data = Uri.parse("data:,archiveAll/${account.uuid}/${System.currentTimeMillis()}")
|
||||
}
|
||||
return PendingIntentCompat.getService(context, 0, intent, FLAG_UPDATE_CURRENT, false)!!
|
||||
}
|
||||
|
||||
override fun createMarkMessageAsSpamPendingIntent(messageReference: MessageReference): PendingIntent {
|
||||
val intent = NotificationActionService.createMarkMessageAsSpamIntent(context, messageReference).apply {
|
||||
data = Uri.parse("data:,spam/${messageReference.toIdentityString()}")
|
||||
}
|
||||
return PendingIntentCompat.getService(context, 0, intent, FLAG_UPDATE_CURRENT, false)!!
|
||||
}
|
||||
|
||||
private fun createMessageListIntent(account: LegacyAccount): Intent {
|
||||
val folderId = defaultFolderProvider.getDefaultFolder(account)
|
||||
val search = LocalMessageSearch().apply {
|
||||
addAllowedFolder(folderId)
|
||||
addAccountUuid(account.uuid)
|
||||
}
|
||||
|
||||
return MessageList.intentDisplaySearch(
|
||||
context = context,
|
||||
search = search,
|
||||
noThreading = false,
|
||||
newTask = true,
|
||||
clearTop = true,
|
||||
).apply {
|
||||
data = Uri.parse("data:,messageList/${account.uuid}/$folderId")
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMessageListIntent(account: LegacyAccount, folderId: Long): Intent {
|
||||
val search = LocalMessageSearch().apply {
|
||||
addAllowedFolder(folderId)
|
||||
addAccountUuid(account.uuid)
|
||||
}
|
||||
|
||||
return MessageList.intentDisplaySearch(
|
||||
context = context,
|
||||
search = search,
|
||||
noThreading = false,
|
||||
newTask = true,
|
||||
clearTop = true,
|
||||
).apply {
|
||||
data = Uri.parse("data:,messageList/${account.uuid}/$folderId")
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMessageViewIntent(messageReference: MessageReference, openInUnifiedInbox: Boolean): Intent {
|
||||
return MessageList.actionDisplayMessageIntent(context, messageReference, openInUnifiedInbox).apply {
|
||||
data = Uri.parse("data:,messageView/${messageReference.toIdentityString()}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun createUnifiedInboxIntent(account: LegacyAccount): Intent {
|
||||
return MessageList.createUnifiedInboxIntent(context, account).apply {
|
||||
data = Uri.parse("data:,unifiedInbox/${account.uuid}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNewMessagesIntent(account: LegacyAccount): Intent {
|
||||
return MessageList.createNewMessagesIntent(context, account).apply {
|
||||
data = Uri.parse("data:,newMessages/${account.uuid}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractFolderIds(messageReferences: List<MessageReference>): Set<Long> {
|
||||
return messageReferences.asSequence().map { it.folderId }.toSet()
|
||||
}
|
||||
|
||||
private fun areAllIncludedInUnifiedInbox(account: LegacyAccount, folderIds: Collection<Long>): Boolean {
|
||||
val messageStore = messageStoreManager.getMessageStore(account)
|
||||
return messageStore.areAllIncludedInUnifiedInbox(folderIds)
|
||||
}
|
||||
|
||||
private fun isIncludedInUnifiedInbox(messageReference: MessageReference): Boolean {
|
||||
val messageStore = messageStoreManager.getMessageStore(messageReference.accountUuid)
|
||||
return messageStore.areAllIncludedInUnifiedInbox(listOf(messageReference.folderId))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
package com.fsck.k9.notification
|
||||
|
||||
import android.content.Context
|
||||
import app.k9mail.core.ui.legacy.designsystem.atom.icon.Icons
|
||||
import com.fsck.k9.ui.R
|
||||
|
||||
class K9NotificationResourceProvider(private val context: Context) : NotificationResourceProvider {
|
||||
override val iconWarning: Int = Icons.Outlined.Warning
|
||||
override val iconMarkAsRead: Int = Icons.Outlined.MarkEmailRead
|
||||
override val iconDelete: Int = Icons.Outlined.Delete
|
||||
override val iconReply: Int = Icons.Outlined.Reply
|
||||
override val iconNewMail: Int = Icons.Outlined.MarkEmailUnread
|
||||
override val iconSendingMail: Int = Icons.Outlined.Sync
|
||||
override val iconCheckingMail: Int = Icons.Outlined.Sync
|
||||
override val iconBackgroundWorkNotification: Int = Icons.Outlined.Bolt
|
||||
override val wearIconMarkAsRead: Int = Icons.Outlined.MarkEmailRead
|
||||
override val wearIconDelete: Int = Icons.Outlined.Delete
|
||||
override val wearIconArchive: Int = Icons.Outlined.Archive
|
||||
override val wearIconReplyAll: Int = Icons.Outlined.Reply
|
||||
override val wearIconMarkAsSpam: Int = Icons.Outlined.Report
|
||||
|
||||
override val pushChannelName: String
|
||||
get() = context.getString(R.string.notification_channel_push_title)
|
||||
override val pushChannelDescription: String
|
||||
get() = context.getString(R.string.notification_channel_push_description)
|
||||
override val messagesChannelName: String
|
||||
get() = context.getString(R.string.notification_channel_messages_title)
|
||||
override val messagesChannelDescription: String
|
||||
get() = context.getString(R.string.notification_channel_messages_description)
|
||||
override val miscellaneousChannelName: String
|
||||
get() = context.getString(R.string.notification_channel_miscellaneous_title)
|
||||
override val miscellaneousChannelDescription: String
|
||||
get() = context.getString(R.string.notification_channel_miscellaneous_description)
|
||||
|
||||
override fun authenticationErrorTitle(): String =
|
||||
context.getString(R.string.notification_authentication_error_title)
|
||||
|
||||
override fun authenticationErrorBody(accountName: String): String =
|
||||
context.getString(R.string.notification_authentication_error_text, accountName)
|
||||
|
||||
override fun notifyErrorTitle(): String = context.getString(R.string.notification_notify_error_title)
|
||||
|
||||
override fun notifyErrorText(): String = context.getString(R.string.notification_notify_error_text)
|
||||
|
||||
override fun certificateErrorTitle(): String = context.getString(R.string.notification_certificate_error_public)
|
||||
|
||||
override fun certificateErrorTitle(accountName: String): String =
|
||||
context.getString(R.string.notification_certificate_error_title, accountName)
|
||||
|
||||
override fun certificateErrorBody(): String = context.getString(R.string.notification_certificate_error_text)
|
||||
|
||||
override fun newMessagesTitle(newMessagesCount: Int): String {
|
||||
return context.resources.getQuantityString(
|
||||
R.plurals.notification_new_messages_title,
|
||||
newMessagesCount,
|
||||
newMessagesCount,
|
||||
)
|
||||
}
|
||||
|
||||
override fun additionalMessages(overflowMessagesCount: Int, accountName: String): String =
|
||||
context.getString(R.string.notification_additional_messages, overflowMessagesCount, accountName)
|
||||
|
||||
override fun previewEncrypted(): String = context.getString(R.string.preview_encrypted)
|
||||
|
||||
override fun noSubject(): String = context.getString(R.string.general_no_subject)
|
||||
|
||||
override fun recipientDisplayName(recipientDisplayName: String): String =
|
||||
context.getString(R.string.message_to_fmt, recipientDisplayName)
|
||||
|
||||
override fun noSender(): String = context.getString(R.string.general_no_sender)
|
||||
|
||||
override fun sendFailedTitle(): String = context.getString(R.string.send_failure_subject)
|
||||
|
||||
override fun sendingMailTitle(): String = context.getString(R.string.notification_bg_send_title)
|
||||
|
||||
override fun sendingMailBody(accountName: String): String =
|
||||
context.getString(R.string.notification_bg_send_ticker, accountName)
|
||||
|
||||
override fun checkingMailTicker(accountName: String, folderName: String): String =
|
||||
context.getString(R.string.notification_bg_sync_ticker, accountName, folderName)
|
||||
|
||||
override fun checkingMailTitle(): String = context.getString(R.string.notification_bg_sync_title)
|
||||
|
||||
override fun checkingMailSeparator(): String = context.getString(R.string.notification_bg_title_separator)
|
||||
|
||||
override fun actionMarkAsRead(): String = context.getString(R.string.notification_action_mark_as_read)
|
||||
|
||||
override fun actionMarkAllAsRead(): String = context.getString(R.string.notification_action_mark_all_as_read)
|
||||
|
||||
override fun actionDelete(): String = context.getString(R.string.notification_action_delete)
|
||||
|
||||
override fun actionDeleteAll(): String = context.getString(R.string.notification_action_delete_all)
|
||||
|
||||
override fun actionReply(): String = context.getString(R.string.notification_action_reply)
|
||||
|
||||
override fun actionArchive(): String = context.getString(R.string.notification_action_archive)
|
||||
|
||||
override fun actionArchiveAll(): String = context.getString(R.string.notification_action_archive_all)
|
||||
|
||||
override fun actionMarkAsSpam(): String = context.getString(R.string.notification_action_spam)
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package com.fsck.k9.notification
|
||||
|
||||
import app.k9mail.core.android.common.contact.ContactRepository
|
||||
import app.k9mail.legacy.di.DI
|
||||
import com.fsck.k9.K9
|
||||
import com.fsck.k9.QuietTimeChecker
|
||||
import com.fsck.k9.mail.Flag
|
||||
import com.fsck.k9.mail.K9MailLib
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.mailstore.LocalFolder
|
||||
import com.fsck.k9.mailstore.LocalMessage
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
import net.thunderbird.core.common.mail.toEmailAddressOrNull
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import net.thunderbird.core.preference.GeneralSettingsManager
|
||||
import net.thunderbird.core.preference.notification.NotificationPreference
|
||||
|
||||
class K9NotificationStrategy(
|
||||
private val contactRepository: ContactRepository,
|
||||
private val generalSettingsManager: GeneralSettingsManager,
|
||||
) : NotificationStrategy {
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
override fun shouldNotifyForMessage(
|
||||
account: LegacyAccount,
|
||||
localFolder: LocalFolder,
|
||||
message: LocalMessage,
|
||||
isOldMessage: Boolean,
|
||||
): Boolean {
|
||||
if (!K9.isNotificationDuringQuietTimeEnabled && generalSettingsManager.getConfig().notification.isQuietTime) {
|
||||
Log.v("No notification: Quiet time is active")
|
||||
return false
|
||||
}
|
||||
|
||||
if (!account.isNotifyNewMail) {
|
||||
Log.v("No notification: Notifications are disabled")
|
||||
return false
|
||||
}
|
||||
|
||||
if (!localFolder.isVisible) {
|
||||
Log.v("No notification: Message is in folder not being displayed")
|
||||
return false
|
||||
}
|
||||
|
||||
if (!localFolder.isNotificationsEnabled) {
|
||||
Log.v("No notification: Notifications are not enabled for this folder")
|
||||
return false
|
||||
}
|
||||
|
||||
if (isOldMessage) {
|
||||
Log.v("No notification: Message is old")
|
||||
return false
|
||||
}
|
||||
|
||||
if (message.isSet(Flag.SEEN)) {
|
||||
Log.v("No notification: Message is marked as read")
|
||||
return false
|
||||
}
|
||||
|
||||
if (account.isIgnoreChatMessages && message.isChatMessage) {
|
||||
Log.v("No notification: Notifications for chat messages are disabled")
|
||||
return false
|
||||
}
|
||||
|
||||
if (!account.isNotifySelfNewMail && account.isAnIdentity(message.from)) {
|
||||
Log.v("No notification: Notifications for messages from yourself are disabled")
|
||||
return false
|
||||
}
|
||||
|
||||
if (account.isNotifyContactsMailOnly &&
|
||||
!contactRepository.hasAnyContactFor(message.from.asList().mapNotNull { it.address.toEmailAddressOrNull() })
|
||||
) {
|
||||
Log.v("No notification: Message is not from a known contact")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private val Message.isChatMessage: Boolean
|
||||
get() = getHeader(K9MailLib.CHAT_HEADER).isNotEmpty()
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
private val NotificationPreference.isQuietTime: Boolean
|
||||
get() {
|
||||
val clock = DI.get<Clock>()
|
||||
val quietTimeChecker = QuietTimeChecker(
|
||||
clock = clock,
|
||||
quietTimeStart = quietTimeStarts,
|
||||
quietTimeEnd = quietTimeEnds,
|
||||
)
|
||||
return quietTimeChecker.isQuietTime
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.fsck.k9.notification
|
||||
|
||||
import org.koin.dsl.module
|
||||
|
||||
val notificationModule = module {
|
||||
single<NotificationActionCreator> {
|
||||
K9NotificationActionCreator(
|
||||
context = get(),
|
||||
defaultFolderProvider = get(),
|
||||
messageStoreManager = get(),
|
||||
generalSettingsManager = get(),
|
||||
)
|
||||
}
|
||||
single<NotificationResourceProvider> { K9NotificationResourceProvider(get()) }
|
||||
single<NotificationStrategy> { K9NotificationStrategy(get(), generalSettingsManager = get()) }
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.fsck.k9.resources
|
||||
|
||||
import android.content.Context
|
||||
import com.fsck.k9.autocrypt.AutocryptStringProvider
|
||||
import com.fsck.k9.ui.R
|
||||
|
||||
class K9AutocryptStringProvider(private val context: Context) : AutocryptStringProvider {
|
||||
override fun transferMessageSubject(): String = context.getString(R.string.ac_transfer_msg_subject)
|
||||
override fun transferMessageBody(): String = context.getString(R.string.ac_transfer_msg_body)
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package com.fsck.k9.resources
|
||||
|
||||
import android.content.Context
|
||||
import app.k9mail.core.ui.legacy.designsystem.atom.icon.Icons
|
||||
import com.fsck.k9.CoreResourceProvider
|
||||
import com.fsck.k9.notification.PushNotificationState
|
||||
import com.fsck.k9.ui.R
|
||||
|
||||
class K9CoreResourceProvider(
|
||||
private val context: Context,
|
||||
) : CoreResourceProvider {
|
||||
override fun defaultIdentityDescription(): String = context.getString(R.string.default_identity_description)
|
||||
|
||||
override fun contactDisplayNamePrefix(): String = context.getString(R.string.message_to_label)
|
||||
override fun contactUnknownSender(): String = context.getString(R.string.unknown_sender)
|
||||
override fun contactUnknownRecipient(): String = context.getString(R.string.unknown_recipient)
|
||||
|
||||
override fun messageHeaderFrom(): String = context.getString(R.string.message_compose_quote_header_from)
|
||||
override fun messageHeaderTo(): String = context.getString(R.string.message_compose_quote_header_to)
|
||||
override fun messageHeaderCc(): String = context.getString(R.string.message_compose_quote_header_cc)
|
||||
override fun messageHeaderDate(): String = context.getString(R.string.message_compose_quote_header_send_date)
|
||||
override fun messageHeaderSubject(): String = context.getString(R.string.message_compose_quote_header_subject)
|
||||
override fun messageHeaderSeparator(): String = context.getString(R.string.message_compose_quote_header_separator)
|
||||
|
||||
override fun noSubject(): String = context.getString(R.string.general_no_subject)
|
||||
|
||||
override fun userAgent(): String = context.getString(R.string.message_header_mua)
|
||||
|
||||
override fun replyHeader(sender: String): String =
|
||||
context.getString(R.string.message_compose_reply_header_fmt, sender)
|
||||
|
||||
override fun replyHeader(sender: String, sentDate: String): String =
|
||||
context.getString(R.string.message_compose_reply_header_fmt_with_date, sentDate, sender)
|
||||
|
||||
override fun searchUnifiedInboxTitle(): String = context.getString(R.string.integrated_inbox_title)
|
||||
override fun searchUnifiedInboxDetail(): String = context.getString(R.string.integrated_inbox_detail)
|
||||
|
||||
override val iconPushNotification: Int = Icons.Outlined.Notifications
|
||||
|
||||
override fun pushNotificationText(notificationState: PushNotificationState): String {
|
||||
val resId = when (notificationState) {
|
||||
PushNotificationState.INITIALIZING -> R.string.push_notification_state_initializing
|
||||
PushNotificationState.LISTENING -> R.string.push_notification_state_listening
|
||||
PushNotificationState.WAIT_BACKGROUND_SYNC -> R.string.push_notification_state_wait_background_sync
|
||||
PushNotificationState.WAIT_NETWORK -> R.string.push_notification_state_wait_network
|
||||
PushNotificationState.ALARM_PERMISSION_MISSING -> R.string.push_notification_state_alarm_permission_missing
|
||||
}
|
||||
return context.getString(resId)
|
||||
}
|
||||
|
||||
override fun pushNotificationInfoText(): String = context.getString(R.string.push_notification_info)
|
||||
|
||||
override fun pushNotificationGrantAlarmPermissionText(): String =
|
||||
context.getString(R.string.push_notification_grant_alarm_permission)
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.fsck.k9.resources
|
||||
|
||||
import com.fsck.k9.CoreResourceProvider
|
||||
import com.fsck.k9.autocrypt.AutocryptStringProvider
|
||||
import org.koin.dsl.module
|
||||
|
||||
val resourcesModule = module {
|
||||
single<CoreResourceProvider> {
|
||||
K9CoreResourceProvider(
|
||||
context = get(),
|
||||
)
|
||||
}
|
||||
single<AutocryptStringProvider> {
|
||||
K9AutocryptStringProvider(
|
||||
context = get(),
|
||||
)
|
||||
}
|
||||
}
|
||||
13
legacy/common/src/main/res/values/manifest_values.xml
Normal file
13
legacy/common/src/main/res/values/manifest_values.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!--
|
||||
We'd like to disable this component by default. However, due to a bug in Android versions prior to 12, users then
|
||||
wouldn't be able to use the home screen widget.
|
||||
See https://android.googlesource.com/platform/frameworks/base/+/85be035336af8d83eb24980026418207c85991cb%5E%21/#F0
|
||||
|
||||
Previously, we've set this value to false on Android 12+ devices. However, a few people have reported widgets
|
||||
disappearing after updating to an app version containing that change. So for now we're back to having widgets
|
||||
enabled by default on all Android versions. We'll try again for Android versions released in or after 2025.
|
||||
-->
|
||||
<bool name="home_screen_widgets_enabled">true</bool>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
package com.fsck.k9.account
|
||||
|
||||
import app.k9mail.feature.account.common.domain.entity.AuthorizationState
|
||||
import app.k9mail.feature.account.edit.AccountEditExternalContract.AccountUpdaterFailure
|
||||
import app.k9mail.feature.account.edit.AccountEditExternalContract.AccountUpdaterResult
|
||||
import assertk.all
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.prop
|
||||
import com.fsck.k9.mail.AuthType
|
||||
import com.fsck.k9.mail.ConnectionSecurity
|
||||
import com.fsck.k9.mail.ServerSettings
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import net.thunderbird.account.fake.FakeAccountData.ACCOUNT_ID_RAW
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import net.thunderbird.core.logging.testing.TestLogger
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import net.thunderbird.core.android.account.LegacyAccount as K9Account
|
||||
|
||||
class AccountServerSettingsUpdaterTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Log.logger = TestLogger()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateServerSettings() SHOULD return account not found exception WHEN none present with uuid`() = runTest {
|
||||
val accountManager = FakeAccountManager(accounts = mutableMapOf())
|
||||
val testSubject = AccountServerSettingsUpdater(accountManager)
|
||||
|
||||
val result = testSubject.updateServerSettings(
|
||||
accountUuid = ACCOUNT_ID_RAW,
|
||||
isIncoming = true,
|
||||
serverSettings = INCOMING_SERVER_SETTINGS,
|
||||
authorizationState = AUTHORIZATION_STATE,
|
||||
)
|
||||
|
||||
assertThat(result).isEqualTo(
|
||||
AccountUpdaterResult.Failure(
|
||||
error = AccountUpdaterFailure.AccountNotFound(ACCOUNT_ID_RAW),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateServerSettings() SHOULD return success with updated incoming settings WHEN is incoming`() = runTest {
|
||||
val accountManager = FakeAccountManager(
|
||||
accounts = mutableMapOf(ACCOUNT_ID_RAW to createAccount(ACCOUNT_ID_RAW)),
|
||||
)
|
||||
val updatedIncomingServerSettings = INCOMING_SERVER_SETTINGS.copy(port = 123)
|
||||
val updatedAuthorizationState = AuthorizationState("new")
|
||||
val testSubject = AccountServerSettingsUpdater(accountManager)
|
||||
|
||||
val result = testSubject.updateServerSettings(
|
||||
accountUuid = ACCOUNT_ID_RAW,
|
||||
isIncoming = true,
|
||||
serverSettings = updatedIncomingServerSettings,
|
||||
authorizationState = updatedAuthorizationState,
|
||||
)
|
||||
|
||||
assertThat(result).isEqualTo(AccountUpdaterResult.Success(ACCOUNT_ID_RAW))
|
||||
|
||||
val k9Account = accountManager.getAccount(ACCOUNT_ID_RAW)
|
||||
assertThat(k9Account).isNotNull().all {
|
||||
prop(K9Account::incomingServerSettings).isEqualTo(updatedIncomingServerSettings)
|
||||
prop(K9Account::outgoingServerSettings).isEqualTo(OUTGOING_SERVER_SETTINGS)
|
||||
prop(K9Account::oAuthState).isEqualTo(updatedAuthorizationState.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateServerSettings() SHOULD return success with updated outgoing settings WHEN is not incoming`() = runTest {
|
||||
val accountManager = FakeAccountManager(
|
||||
accounts = mutableMapOf(ACCOUNT_ID_RAW to createAccount(ACCOUNT_ID_RAW)),
|
||||
)
|
||||
val updatedOutgoingServerSettings = OUTGOING_SERVER_SETTINGS.copy(port = 123)
|
||||
val updatedAuthorizationState = AuthorizationState("new")
|
||||
val testSubject = AccountServerSettingsUpdater(accountManager)
|
||||
|
||||
val result = testSubject.updateServerSettings(
|
||||
accountUuid = ACCOUNT_ID_RAW,
|
||||
isIncoming = false,
|
||||
serverSettings = updatedOutgoingServerSettings,
|
||||
authorizationState = updatedAuthorizationState,
|
||||
)
|
||||
|
||||
assertThat(result).isEqualTo(AccountUpdaterResult.Success(ACCOUNT_ID_RAW))
|
||||
|
||||
val k9Account = accountManager.getAccount(ACCOUNT_ID_RAW)
|
||||
assertThat(k9Account).isNotNull().all {
|
||||
prop(K9Account::incomingServerSettings).isEqualTo(INCOMING_SERVER_SETTINGS)
|
||||
prop(K9Account::outgoingServerSettings).isEqualTo(updatedOutgoingServerSettings)
|
||||
prop(K9Account::oAuthState).isEqualTo(updatedAuthorizationState.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateServerSettings() SHOULD return unknown error when exception thrown`() = runTest {
|
||||
val accountManager = FakeAccountManager(
|
||||
accounts = mutableMapOf(ACCOUNT_ID_RAW to createAccount(ACCOUNT_ID_RAW)),
|
||||
isFailureOnSave = true,
|
||||
)
|
||||
val testSubject = AccountServerSettingsUpdater(accountManager)
|
||||
|
||||
val result = testSubject.updateServerSettings(
|
||||
accountUuid = ACCOUNT_ID_RAW,
|
||||
isIncoming = true,
|
||||
serverSettings = INCOMING_SERVER_SETTINGS,
|
||||
authorizationState = AUTHORIZATION_STATE,
|
||||
)
|
||||
|
||||
assertThat(result).isInstanceOf<AccountUpdaterResult.Failure>()
|
||||
.prop(AccountUpdaterResult.Failure::error).isInstanceOf<AccountUpdaterFailure.UnknownError>()
|
||||
.prop(AccountUpdaterFailure.UnknownError::error).isInstanceOf<Exception>()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val INCOMING_SERVER_SETTINGS = ServerSettings(
|
||||
type = "pop3",
|
||||
host = "pop.example.org",
|
||||
port = 465,
|
||||
connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = "username",
|
||||
password = "password",
|
||||
clientCertificateAlias = null,
|
||||
extra = emptyMap(),
|
||||
)
|
||||
|
||||
val OUTGOING_SERVER_SETTINGS = ServerSettings(
|
||||
type = "smtp",
|
||||
host = "smtp.example.org",
|
||||
port = 587,
|
||||
connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = "username",
|
||||
password = "password",
|
||||
clientCertificateAlias = null,
|
||||
extra = emptyMap(),
|
||||
)
|
||||
|
||||
val AUTHORIZATION_STATE = AuthorizationState("auth state")
|
||||
|
||||
fun createAccount(accountUuid: String): K9Account {
|
||||
return K9Account(
|
||||
uuid = accountUuid,
|
||||
).apply {
|
||||
incomingServerSettings = INCOMING_SERVER_SETTINGS
|
||||
outgoingServerSettings = OUTGOING_SERVER_SETTINGS
|
||||
oAuthState = AUTHORIZATION_STATE.value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
package com.fsck.k9.account
|
||||
|
||||
import app.k9mail.feature.account.common.domain.entity.AccountState
|
||||
import app.k9mail.feature.account.common.domain.entity.AuthorizationState
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNull
|
||||
import com.fsck.k9.mail.AuthType
|
||||
import com.fsck.k9.mail.ConnectionSecurity
|
||||
import com.fsck.k9.mail.ServerSettings
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import net.thunderbird.account.fake.FakeAccountData.ACCOUNT_ID_RAW
|
||||
import net.thunderbird.core.android.account.Identity
|
||||
import net.thunderbird.core.android.account.LegacyAccount
|
||||
import org.junit.Test
|
||||
|
||||
class AccountStateLoaderTest {
|
||||
|
||||
@Test
|
||||
fun `loadAccountState() SHOULD return null when accountManager returns null`() = runTest {
|
||||
val accountManager = FakeAccountManager()
|
||||
val testSubject = AccountStateLoader(accountManager)
|
||||
|
||||
val result = testSubject.loadAccountState(ACCOUNT_ID_RAW)
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadAccountState() SHOULD return account when present in accountManager`() = runTest {
|
||||
val accounts = mutableMapOf(
|
||||
ACCOUNT_ID_RAW to LegacyAccount(uuid = ACCOUNT_ID_RAW).apply {
|
||||
identities = mutableListOf(Identity())
|
||||
email = "emailAddress"
|
||||
incomingServerSettings = INCOMING_SERVER_SETTINGS
|
||||
outgoingServerSettings = OUTGOING_SERVER_SETTINGS
|
||||
oAuthState = "oAuthState"
|
||||
},
|
||||
)
|
||||
val accountManager = FakeAccountManager(accounts = accounts)
|
||||
val testSubject = AccountStateLoader(accountManager)
|
||||
|
||||
val result = testSubject.loadAccountState(ACCOUNT_ID_RAW)
|
||||
|
||||
assertThat(result).isEqualTo(
|
||||
AccountState(
|
||||
uuid = ACCOUNT_ID_RAW,
|
||||
emailAddress = "emailAddress",
|
||||
incomingServerSettings = INCOMING_SERVER_SETTINGS,
|
||||
outgoingServerSettings = OUTGOING_SERVER_SETTINGS,
|
||||
authorizationState = AuthorizationState("oAuthState"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val INCOMING_SERVER_SETTINGS = ServerSettings(
|
||||
type = "pop3",
|
||||
host = "pop.example.org",
|
||||
port = 465,
|
||||
connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = "username",
|
||||
password = "password",
|
||||
clientCertificateAlias = null,
|
||||
extra = emptyMap(),
|
||||
)
|
||||
|
||||
val OUTGOING_SERVER_SETTINGS = ServerSettings(
|
||||
type = "smtp",
|
||||
host = "smtp.example.org",
|
||||
port = 587,
|
||||
connectionSecurity = ConnectionSecurity.SSL_TLS_REQUIRED,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = "username",
|
||||
password = "password",
|
||||
clientCertificateAlias = null,
|
||||
extra = emptyMap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package com.fsck.k9.account
|
||||
|
||||
import assertk.assertFailure
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import net.thunderbird.core.android.account.DeletePolicy
|
||||
import net.thunderbird.core.common.mail.Protocols
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultDeletePolicyProviderTest {
|
||||
private val deletePolicyProvider = DefaultDeletePolicyProvider()
|
||||
|
||||
@Test
|
||||
fun `getDeletePolicy with IMAP should return ON_DELETE`() {
|
||||
val result = deletePolicyProvider.getDeletePolicy(Protocols.IMAP)
|
||||
|
||||
assertThat(result).isEqualTo(DeletePolicy.ON_DELETE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDeletePolicy with POP3 should return NEVER`() {
|
||||
val result = deletePolicyProvider.getDeletePolicy(Protocols.POP3)
|
||||
|
||||
assertThat(result).isEqualTo(DeletePolicy.NEVER)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDeletePolicy with demo should return ON_DELETE`() {
|
||||
val result = deletePolicyProvider.getDeletePolicy("demo")
|
||||
|
||||
assertThat(result).isEqualTo(DeletePolicy.ON_DELETE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDeletePolicy with SMTP should fail`() {
|
||||
assertFailure {
|
||||
deletePolicyProvider.getDeletePolicy(Protocols.SMTP)
|
||||
}.isInstanceOf<AssertionError>()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
package com.fsck.k9.account
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
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
|
||||
|
||||
class FakeAccountManager(
|
||||
private val accounts: MutableMap<String, LegacyAccount> = mutableMapOf(),
|
||||
private val isFailureOnSave: Boolean = false,
|
||||
) : AccountManager {
|
||||
|
||||
override fun getAccounts(): List<LegacyAccount> = accounts.values.toList()
|
||||
|
||||
override fun getAccountsFlow(): Flow<List<LegacyAccount>> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun getAccount(accountUuid: String): LegacyAccount? = accounts[accountUuid]
|
||||
|
||||
override fun getAccountFlow(accountUuid: String): Flow<LegacyAccount> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun addAccountRemovedListener(listener: AccountRemovedListener) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun moveAccount(account: LegacyAccount, newPosition: Int) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun addOnAccountsChangeListener(accountsChangeListener: AccountsChangeListener) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun removeOnAccountsChangeListener(accountsChangeListener: AccountsChangeListener) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionThrown")
|
||||
override fun saveAccount(account: LegacyAccount) {
|
||||
if (isFailureOnSave) {
|
||||
throw Exception("FakeAccountManager.saveAccount() failed")
|
||||
}
|
||||
accounts[account.uuid] = account
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
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