Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 13:56:56 +01:00
parent 75dc487a7a
commit 39c29d175b
6317 changed files with 388324 additions and 2 deletions

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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