summaryrefslogtreecommitdiff
path: root/app/k9mail/src/main/java
diff options
context:
space:
mode:
authortest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
committertest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
commit50ce5d5e6cd0d720ff437d35eace545816848428 (patch)
treeb59af206a36265cf081945e434900b32b0c43edd /app/k9mail/src/main/java
downloadmonocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.gz
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.bz2
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.zip
Inital
Diffstat (limited to 'app/k9mail/src/main/java')
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/App.kt145
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/Dependencies.kt44
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/MessagingListenerProvider.kt5
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/auth/OAuthConfigurations.kt46
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/backends/AndroidAlarmManager.kt71
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/backends/ImapBackendFactory.kt97
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/backends/KoinModule.kt51
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/backends/Pop3BackendFactory.kt35
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/backends/RealOAuth2TokenProvider.kt76
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/backends/WebDavBackendFactory.kt35
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/glide/K9AppGlideModule.java8
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationActionCreator.kt264
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationResourceProvider.kt100
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationStrategy.kt102
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/notification/KoinModule.kt11
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/provider/UnreadWidgetProvider.kt103
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/resources/K9AutocryptStringProvider.kt10
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/resources/K9CoreResourceProvider.kt52
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/resources/KoinModule.kt10
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/widget/list/KoinModule.kt8
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetProvider.kt9
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/widget/unread/KoinModule.kt20
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetConfigurationActivity.kt39
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetConfigurationFragment.kt226
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetData.kt10
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetDataProvider.kt98
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetMigrations.kt44
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetRepository.kt62
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetUpdateListener.kt29
-rw-r--r--app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetUpdater.kt30
30 files changed, 1840 insertions, 0 deletions
diff --git a/app/k9mail/src/main/java/com/fsck/k9/App.kt b/app/k9mail/src/main/java/com/fsck/k9/App.kt
new file mode 100644
index 0000000..c480171
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/App.kt
@@ -0,0 +1,145 @@
+package com.fsck.k9
+
+import android.app.Application
+import android.content.res.Configuration
+import android.content.res.Resources
+import app.k9mail.ui.widget.list.MessageListWidgetManager
+import com.fsck.k9.activity.LauncherShortcuts
+import com.fsck.k9.activity.MessageCompose
+import com.fsck.k9.controller.MessagingController
+import com.fsck.k9.job.WorkManagerConfigurationProvider
+import com.fsck.k9.notification.NotificationChannelManager
+import com.fsck.k9.provider.UnreadWidgetProvider
+import com.fsck.k9.ui.base.AppLanguageManager
+import com.fsck.k9.ui.base.ThemeManager
+import com.fsck.k9.ui.base.extensions.currentLocale
+import com.fsck.k9.widget.list.MessageListWidgetProvider
+import java.util.Locale
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.plus
+import org.koin.android.ext.android.inject
+import timber.log.Timber
+import androidx.work.Configuration as WorkManagerConfiguration
+
+class App : Application(), WorkManagerConfiguration.Provider {
+ private val messagingController: MessagingController by inject()
+ private val messagingListenerProvider: MessagingListenerProvider by inject()
+ private val themeManager: ThemeManager by inject()
+ private val appLanguageManager: AppLanguageManager by inject()
+ private val notificationChannelManager: NotificationChannelManager by inject()
+ private val messageListWidgetManager: MessageListWidgetManager by inject()
+ private val workManagerConfigurationProvider: WorkManagerConfigurationProvider by inject()
+
+ private val appCoroutineScope: CoroutineScope = GlobalScope + Dispatchers.Main
+ private var appLanguageManagerInitialized = false
+
+ override fun onCreate() {
+ Core.earlyInit()
+
+ super.onCreate()
+
+ DI.start(this, coreModules + uiModules + appModules)
+
+ K9.init(this)
+ Core.init(this)
+ initializeAppLanguage()
+ updateNotificationChannelsOnAppLanguageChanges()
+ themeManager.init()
+ messageListWidgetManager.init()
+
+ messagingListenerProvider.listeners.forEach { listener ->
+ messagingController.addListener(listener)
+ }
+ }
+
+ private fun initializeAppLanguage() {
+ appLanguageManager.init()
+ applyOverrideLocaleToConfiguration()
+ appLanguageManagerInitialized = true
+ listenForAppLanguageChanges()
+ }
+
+ private fun applyOverrideLocaleToConfiguration() {
+ appLanguageManager.getOverrideLocale()?.let { overrideLocale ->
+ updateConfigurationWithLocale(superResources.configuration, overrideLocale)
+ }
+ }
+
+ private fun listenForAppLanguageChanges() {
+ appLanguageManager.overrideLocale
+ .drop(1) // We already applied the initial value
+ .onEach { overrideLocale ->
+ val locale = overrideLocale ?: Locale.getDefault()
+ updateConfigurationWithLocale(superResources.configuration, locale)
+ }
+ .launchIn(appCoroutineScope)
+ }
+
+ override fun onConfigurationChanged(newConfiguration: Configuration) {
+ applyOverrideLocaleToConfiguration()
+ super.onConfigurationChanged(superResources.configuration)
+ }
+
+ private fun updateConfigurationWithLocale(configuration: Configuration, locale: Locale) {
+ Timber.d("Updating application configuration with locale '$locale'")
+
+ val newConfiguration = Configuration(configuration).apply {
+ currentLocale = locale
+ }
+
+ @Suppress("DEPRECATION")
+ superResources.updateConfiguration(newConfiguration, superResources.displayMetrics)
+ }
+
+ private val superResources: Resources
+ get() = super.getResources()
+
+ // Creating a WebView instance triggers something that will cause the configuration of the Application's Resources
+ // instance to be reset to the default, i.e. not containing our locale override. Unfortunately, we're not notified
+ // about this event. So we're checking each time someone asks for the Resources instance whether we need to change
+ // the configuration again. Luckily, right now (Android 11), the platform is calling this method right after
+ // resetting the configuration.
+ override fun getResources(): Resources {
+ val resources = super.getResources()
+
+ if (appLanguageManagerInitialized) {
+ appLanguageManager.getOverrideLocale()?.let { overrideLocale ->
+ if (resources.configuration.currentLocale != overrideLocale) {
+ Timber.w("Resources configuration was reset. Re-applying locale override.")
+ appLanguageManager.applyOverrideLocale()
+ applyOverrideLocaleToConfiguration()
+ }
+ }
+ }
+
+ return resources
+ }
+
+ private fun updateNotificationChannelsOnAppLanguageChanges() {
+ appLanguageManager.appLocale
+ .distinctUntilChanged()
+ .onEach { notificationChannelManager.updateChannels() }
+ .launchIn(appCoroutineScope)
+ }
+
+ override fun getWorkManagerConfiguration(): WorkManagerConfiguration {
+ return workManagerConfigurationProvider.getConfiguration()
+ }
+
+ companion object {
+ val appConfig = AppConfig(
+ componentsToDisable = listOf(
+ MessageCompose::class.java,
+ LauncherShortcuts::class.java,
+ UnreadWidgetProvider::class.java,
+ MessageListWidgetProvider::class.java
+ )
+ )
+ }
+}
diff --git a/app/k9mail/src/main/java/com/fsck/k9/Dependencies.kt b/app/k9mail/src/main/java/com/fsck/k9/Dependencies.kt
new file mode 100644
index 0000000..76936bb
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/Dependencies.kt
@@ -0,0 +1,44 @@
+package com.fsck.k9
+
+import app.k9mail.ui.widget.list.messageListWidgetModule
+import com.fsck.k9.auth.createOAuthConfigurationProvider
+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.preferences.StoragePersister
+import com.fsck.k9.resources.resourcesModule
+import com.fsck.k9.storage.storageModule
+import com.fsck.k9.widget.list.messageListWidgetConfigModule
+import com.fsck.k9.widget.unread.UnreadWidgetUpdateListener
+import com.fsck.k9.widget.unread.unreadWidgetModule
+import org.koin.core.qualifier.named
+import org.koin.dsl.module
+
+private val mainAppModule = module {
+ single { App.appConfig }
+ single {
+ MessagingListenerProvider(
+ listOf(
+ get<UnreadWidgetUpdateListener>()
+ )
+ )
+ }
+ single(named("controllerExtensions")) { emptyList<ControllerExtension>() }
+ single<EncryptionExtractor> { OpenPgpEncryptionExtractor.newInstance() }
+ single<StoragePersister> { K9StoragePersister(get()) }
+ single { createOAuthConfigurationProvider() }
+}
+
+val appModules = listOf(
+ mainAppModule,
+ messageListWidgetConfigModule,
+ messageListWidgetModule,
+ unreadWidgetModule,
+ notificationModule,
+ resourcesModule,
+ backendsModule,
+ storageModule
+)
diff --git a/app/k9mail/src/main/java/com/fsck/k9/MessagingListenerProvider.kt b/app/k9mail/src/main/java/com/fsck/k9/MessagingListenerProvider.kt
new file mode 100644
index 0000000..b81d41a
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/MessagingListenerProvider.kt
@@ -0,0 +1,5 @@
+package com.fsck.k9
+
+import com.fsck.k9.controller.MessagingListener
+
+class MessagingListenerProvider(val listeners: List<MessagingListener>)
diff --git a/app/k9mail/src/main/java/com/fsck/k9/auth/OAuthConfigurations.kt b/app/k9mail/src/main/java/com/fsck/k9/auth/OAuthConfigurations.kt
new file mode 100644
index 0000000..4f1b5f4
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/auth/OAuthConfigurations.kt
@@ -0,0 +1,46 @@
+package com.fsck.k9.auth
+
+import com.fsck.k9.BuildConfig
+import com.fsck.k9.oauth.OAuthConfiguration
+import com.fsck.k9.oauth.OAuthConfigurationProvider
+
+fun createOAuthConfigurationProvider(): OAuthConfigurationProvider {
+ val redirectUriSlash = BuildConfig.APPLICATION_ID + ":/oauth2redirect"
+ val redirectUriDoubleSlash = BuildConfig.APPLICATION_ID + "://oauth2redirect"
+
+ val googleConfig = OAuthConfiguration(
+ clientId = BuildConfig.OAUTH_GMAIL_CLIENT_ID,
+ scopes = listOf("https://mail.google.com/"),
+ authorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth",
+ tokenEndpoint = "https://oauth2.googleapis.com/token",
+ redirectUri = redirectUriSlash
+ )
+
+ return OAuthConfigurationProvider(
+ configurations = mapOf(
+ listOf("imap.gmail.com", "imap.googlemail.com", "smtp.gmail.com", "smtp.googlemail.com") to googleConfig,
+ listOf("imap.mail.yahoo.com", "smtp.mail.yahoo.com") to OAuthConfiguration(
+ clientId = BuildConfig.OAUTH_YAHOO_CLIENT_ID,
+ scopes = listOf("mail-w"),
+ authorizationEndpoint = "https://api.login.yahoo.com/oauth2/request_auth",
+ tokenEndpoint = "https://api.login.yahoo.com/oauth2/get_token",
+ redirectUri = redirectUriDoubleSlash
+ ),
+ listOf("imap.aol.com", "smtp.aol.com") to OAuthConfiguration(
+ clientId = BuildConfig.OAUTH_AOL_CLIENT_ID,
+ scopes = listOf("mail-w"),
+ authorizationEndpoint = "https://api.login.aol.com/oauth2/request_auth",
+ tokenEndpoint = "https://api.login.aol.com/oauth2/get_token",
+ redirectUri = redirectUriDoubleSlash
+ ),
+ listOf("outlook.office365.com", "smtp.office365.com") to OAuthConfiguration(
+ clientId = BuildConfig.OAUTH_MICROSOFT_CLIENT_ID,
+ scopes = listOf("https://outlook.office.com/IMAP.AccessAsUser.All", "https://outlook.office.com/SMTP.Send", "offline_access"),
+ authorizationEndpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
+ tokenEndpoint = "https://login.microsoftonline.com/common/oauth2/v2.0/token",
+ redirectUri = BuildConfig.OAUTH_MICROSOFT_REDIRECT_URI
+ )
+ ),
+ googleConfiguration = googleConfig
+ )
+}
diff --git a/app/k9mail/src/main/java/com/fsck/k9/backends/AndroidAlarmManager.kt b/app/k9mail/src/main/java/com/fsck/k9/backends/AndroidAlarmManager.kt
new file mode 100644
index 0000000..0a09dfa
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/backends/AndroidAlarmManager.kt
@@ -0,0 +1,71 @@
+package com.fsck.k9.backends
+
+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 com.fsck.k9.backend.imap.SystemAlarmManager
+import com.fsck.k9.helper.AlarmManagerCompat
+import com.fsck.k9.helper.PendingIntentCompat.FLAG_IMMUTABLE
+import java.util.concurrent.atomic.AtomicReference
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+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: AlarmManagerCompat,
+ backgroundDispatcher: CoroutineDispatcher = Dispatchers.IO
+) : SystemAlarmManager {
+ private val coroutineScope = CoroutineScope(backgroundDispatcher)
+
+ private val pendingIntent: PendingIntent = run {
+ val intent = Intent(ALARM_ACTION).apply {
+ setPackage(context.packageName)
+ }
+
+ PendingIntent.getBroadcast(context, REQUEST_CODE, intent, FLAG_IMMUTABLE)
+ }
+
+ private val callback = AtomicReference<Callback?>(null)
+
+ init {
+ val intentFilter = IntentFilter(ALARM_ACTION)
+ context.registerReceiver(
+ object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ val callback = callback.getAndSet(null)
+ if (callback == null) {
+ Timber.w("Alarm triggered but 'callback' was null")
+ } else {
+ coroutineScope.launch {
+ callback.invoke()
+ }
+ }
+ }
+ },
+ intentFilter
+ )
+ }
+
+ override fun setAlarm(triggerTime: Long, callback: Callback) {
+ this.callback.set(callback)
+ alarmManager.scheduleAlarm(triggerTime, pendingIntent)
+ }
+
+ override fun cancelAlarm() {
+ callback.set(null)
+ alarmManager.cancelAlarm(pendingIntent)
+ }
+
+ override fun now(): Long = SystemClock.elapsedRealtime()
+}
diff --git a/app/k9mail/src/main/java/com/fsck/k9/backends/ImapBackendFactory.kt b/app/k9mail/src/main/java/com/fsck/k9/backends/ImapBackendFactory.kt
new file mode 100644
index 0000000..2b14b1b
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/backends/ImapBackendFactory.kt
@@ -0,0 +1,97 @@
+package com.fsck.k9.backends
+
+import android.content.Context
+import com.fsck.k9.Account
+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.ImapStore
+import com.fsck.k9.mail.store.imap.ImapStoreConfig
+import com.fsck.k9.mail.transport.smtp.SmtpTransport
+import com.fsck.k9.mailstore.K9BackendStorageFactory
+import com.fsck.k9.preferences.AccountManager
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+
+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
+) : BackendFactory {
+ override fun createBackend(account: Account): 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: Account): ImapStore {
+ val oAuth2TokenProvider = if (account.incomingServerSettings.authenticationType == AuthType.XOAUTH2) {
+ RealOAuth2TokenProvider(context, accountManager, account)
+ } else {
+ null
+ }
+
+ val config = createImapStoreConfig(account)
+ return ImapStore.create(
+ account.incomingServerSettings,
+ config,
+ trustedSocketFactory,
+ oAuth2TokenProvider
+ )
+ }
+
+ private fun createImapStoreConfig(account: Account): ImapStoreConfig {
+ return object : ImapStoreConfig {
+ override val logLabel
+ get() = account.uuid
+
+ override fun isSubscribedFoldersOnly() = account.isSubscribedFoldersOnly
+
+ override fun useCompression() = account.useCompression
+ }
+ }
+
+ private fun createSmtpTransport(account: Account): SmtpTransport {
+ val serverSettings = account.outgoingServerSettings
+ val oauth2TokenProvider = if (serverSettings.authenticationType == AuthType.XOAUTH2) {
+ RealOAuth2TokenProvider(context, accountManager, account)
+ } else {
+ null
+ }
+
+ return SmtpTransport(serverSettings, trustedSocketFactory, oauth2TokenProvider)
+ }
+
+ private fun createPushConfigProvider(account: Account) = object : ImapPushConfigProvider {
+ override val maxPushFoldersFlow: Flow<Int>
+ get() = accountManager.getAccountFlow(account.uuid)
+ .map { it.maxPushFolders }
+ .distinctUntilChanged()
+
+ override val idleRefreshMinutesFlow: Flow<Int>
+ get() = accountManager.getAccountFlow(account.uuid)
+ .map { it.idleRefreshMinutes }
+ .distinctUntilChanged()
+ }
+}
diff --git a/app/k9mail/src/main/java/com/fsck/k9/backends/KoinModule.kt b/app/k9mail/src/main/java/com/fsck/k9/backends/KoinModule.kt
new file mode 100644
index 0000000..b231776
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/backends/KoinModule.kt
@@ -0,0 +1,51 @@
+package com.fsck.k9.backends
+
+import app.k9mail.dev.developmentBackends
+import app.k9mail.dev.developmentModuleAdditions
+import com.fsck.k9.backend.BackendManager
+import com.fsck.k9.backend.imap.BackendIdleRefreshManager
+import com.fsck.k9.backend.imap.SystemAlarmManager
+import com.fsck.k9.helper.DefaultTrustedSocketFactory
+import com.fsck.k9.mail.store.imap.IdleRefreshManager
+import com.fsck.k9.mail.store.webdav.SniHostSetter
+import org.koin.dsl.module
+
+val backendsModule = module {
+ single {
+ BackendManager(
+ mapOf(
+ "imap" to get<ImapBackendFactory>(),
+ "pop3" to get<Pop3BackendFactory>(),
+ "webdav" to get<WebDavBackendFactory>()
+ ) + developmentBackends()
+ )
+ }
+ single {
+ ImapBackendFactory(
+ accountManager = get(),
+ powerManager = get(),
+ idleRefreshManager = get(),
+ backendStorageFactory = get(),
+ trustedSocketFactory = get(),
+ context = get()
+ )
+ }
+ single<SystemAlarmManager> { AndroidAlarmManager(context = get(), alarmManager = get()) }
+ single<IdleRefreshManager> { BackendIdleRefreshManager(alarmManager = get()) }
+ single { Pop3BackendFactory(get(), get()) }
+ single {
+ WebDavBackendFactory(
+ backendStorageFactory = get(),
+ trustManagerFactory = get(),
+ sniHostSetter = get(),
+ folderRepository = get()
+ )
+ }
+ single {
+ SniHostSetter { factory, socket, hostname ->
+ DefaultTrustedSocketFactory.setSniHost(factory, socket, hostname)
+ }
+ }
+
+ developmentModuleAdditions()
+}
diff --git a/app/k9mail/src/main/java/com/fsck/k9/backends/Pop3BackendFactory.kt b/app/k9mail/src/main/java/com/fsck/k9/backends/Pop3BackendFactory.kt
new file mode 100644
index 0000000..4550f5f
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/backends/Pop3BackendFactory.kt
@@ -0,0 +1,35 @@
+package com.fsck.k9.backends
+
+import com.fsck.k9.Account
+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
+
+class Pop3BackendFactory(
+ private val backendStorageFactory: K9BackendStorageFactory,
+ private val trustedSocketFactory: TrustedSocketFactory
+) : BackendFactory {
+ override fun createBackend(account: Account): 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: Account): Pop3Store {
+ val serverSettings = account.incomingServerSettings
+ return Pop3Store(serverSettings, trustedSocketFactory)
+ }
+
+ private fun createSmtpTransport(account: Account): SmtpTransport {
+ val serverSettings = account.outgoingServerSettings
+ val oauth2TokenProvider: OAuth2TokenProvider? = null
+ return SmtpTransport(serverSettings, trustedSocketFactory, oauth2TokenProvider)
+ }
+}
diff --git a/app/k9mail/src/main/java/com/fsck/k9/backends/RealOAuth2TokenProvider.kt b/app/k9mail/src/main/java/com/fsck/k9/backends/RealOAuth2TokenProvider.kt
new file mode 100644
index 0000000..48402de
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/backends/RealOAuth2TokenProvider.kt
@@ -0,0 +1,76 @@
+package com.fsck.k9.backends
+
+import android.content.Context
+import com.fsck.k9.Account
+import com.fsck.k9.mail.AuthenticationFailedException
+import com.fsck.k9.mail.oauth.OAuth2TokenProvider
+import com.fsck.k9.preferences.AccountManager
+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
+
+class RealOAuth2TokenProvider(
+ context: Context,
+ private val accountManager: AccountManager,
+ private val account: Account
+) : OAuth2TokenProvider {
+ private val authService = AuthorizationService(context)
+ private var requestFreshToken = false
+
+ override fun getToken(timeoutMillis: Long): String {
+ val latch = CountDownLatch(1)
+ var token: String? = null
+ var exception: AuthorizationException? = null
+
+ val authState = account.oAuthState?.let { AuthState.jsonDeserialize(it) }
+ ?: throw AuthenticationFailedException("Login required")
+
+ if (requestFreshToken) {
+ authState.needsTokenRefresh = true
+ }
+
+ val oldAccessToken = authState.accessToken
+
+ authState.performActionWithFreshTokens(authService) { accessToken: String?, _, authException: AuthorizationException? ->
+ token = accessToken
+ exception = authException
+
+ latch.countDown()
+ }
+
+ latch.await(timeoutMillis, TimeUnit.MILLISECONDS)
+
+ 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) {
+ account.oAuthState = null
+ accountManager.saveAccount(account)
+
+ throw AuthenticationFailedException(
+ message = "Failed to fetch an access token",
+ throwable = authException,
+ messageFromServer = authException.error
+ )
+ } else if (token != oldAccessToken) {
+ requestFreshToken = false
+ account.oAuthState = authState.jsonSerializeString()
+ accountManager.saveAccount(account)
+ }
+
+ return token ?: throw AuthenticationFailedException("Failed to fetch an access token")
+ }
+
+ override fun invalidateToken() {
+ requestFreshToken = true
+ }
+}
diff --git a/app/k9mail/src/main/java/com/fsck/k9/backends/WebDavBackendFactory.kt b/app/k9mail/src/main/java/com/fsck/k9/backends/WebDavBackendFactory.kt
new file mode 100644
index 0000000..f8bf8f7
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/backends/WebDavBackendFactory.kt
@@ -0,0 +1,35 @@
+package com.fsck.k9.backends
+
+import com.fsck.k9.Account
+import com.fsck.k9.backend.BackendFactory
+import com.fsck.k9.backend.api.Backend
+import com.fsck.k9.backend.webdav.WebDavBackend
+import com.fsck.k9.mail.ssl.TrustManagerFactory
+import com.fsck.k9.mail.store.webdav.DraftsFolderProvider
+import com.fsck.k9.mail.store.webdav.SniHostSetter
+import com.fsck.k9.mail.store.webdav.WebDavStore
+import com.fsck.k9.mailstore.FolderRepository
+import com.fsck.k9.mailstore.K9BackendStorageFactory
+
+class WebDavBackendFactory(
+ private val backendStorageFactory: K9BackendStorageFactory,
+ private val trustManagerFactory: TrustManagerFactory,
+ private val sniHostSetter: SniHostSetter,
+ private val folderRepository: FolderRepository
+) : BackendFactory {
+ override fun createBackend(account: Account): Backend {
+ val accountName = account.displayName
+ val backendStorage = backendStorageFactory.createBackendStorage(account)
+ val serverSettings = account.incomingServerSettings
+ val draftsFolderProvider = createDraftsFolderProvider(account)
+ val webDavStore = WebDavStore(trustManagerFactory, sniHostSetter, serverSettings, draftsFolderProvider)
+ return WebDavBackend(accountName, backendStorage, webDavStore)
+ }
+
+ private fun createDraftsFolderProvider(account: Account): DraftsFolderProvider {
+ return DraftsFolderProvider {
+ val draftsFolderId = account.draftsFolderId ?: error("No Drafts folder configured")
+ folderRepository.getFolderServerId(account, draftsFolderId) ?: error("Couldn't find local Drafts folder")
+ }
+ }
+}
diff --git a/app/k9mail/src/main/java/com/fsck/k9/glide/K9AppGlideModule.java b/app/k9mail/src/main/java/com/fsck/k9/glide/K9AppGlideModule.java
new file mode 100644
index 0000000..f37999f
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/glide/K9AppGlideModule.java
@@ -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 {
+}
diff --git a/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationActionCreator.kt b/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationActionCreator.kt
new file mode 100644
index 0000000..b08374a
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationActionCreator.kt
@@ -0,0 +1,264 @@
+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 com.fsck.k9.Account
+import com.fsck.k9.K9
+import com.fsck.k9.activity.MessageList
+import com.fsck.k9.activity.compose.MessageActions
+import com.fsck.k9.activity.setup.AccountSetupIncoming
+import com.fsck.k9.activity.setup.AccountSetupOutgoing
+import com.fsck.k9.controller.MessageReference
+import com.fsck.k9.helper.PendingIntentCompat.FLAG_IMMUTABLE
+import com.fsck.k9.mailstore.MessageStoreManager
+import com.fsck.k9.search.LocalSearch
+import com.fsck.k9.ui.messagelist.DefaultFolderProvider
+import com.fsck.k9.ui.notification.DeleteConfirmationActivity
+
+/**
+ * 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
+) : NotificationActionCreator {
+
+ override fun createViewMessagePendingIntent(messageReference: MessageReference): PendingIntent {
+ val openInUnifiedInbox = K9.isShowUnifiedInbox && isIncludedInUnifiedInbox(messageReference)
+ val intent = createMessageViewIntent(messageReference, openInUnifiedInbox)
+
+ return PendingIntent.getActivity(context, 0, intent, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE)
+ }
+
+ override fun createViewFolderPendingIntent(account: Account, folderId: Long): PendingIntent {
+ val intent = createMessageListIntent(account, folderId)
+ return PendingIntent.getActivity(context, 0, intent, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE)
+ }
+
+ override fun createViewMessagesPendingIntent(
+ account: Account,
+ messageReferences: List<MessageReference>
+ ): PendingIntent {
+ val folderIds = extractFolderIds(messageReferences)
+
+ val intent = if (K9.isShowUnifiedInbox && areAllIncludedInUnifiedInbox(account, folderIds)) {
+ createUnifiedInboxIntent(account)
+ } else if (folderIds.size == 1) {
+ createMessageListIntent(account, folderIds.first())
+ } else {
+ createNewMessagesIntent(account)
+ }
+
+ return PendingIntent.getActivity(context, 0, intent, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE)
+ }
+
+ override fun createViewFolderListPendingIntent(account: Account): PendingIntent {
+ val intent = createMessageListIntent(account)
+ return PendingIntent.getActivity(context, 0, intent, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE)
+ }
+
+ override fun createDismissAllMessagesPendingIntent(account: Account): PendingIntent {
+ val intent = NotificationActionService.createDismissAllMessagesIntent(context, account).apply {
+ data = Uri.parse("data:,dismissAll/${account.uuid}/${System.currentTimeMillis()}")
+ }
+ return PendingIntent.getService(context, 0, intent, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE)
+ }
+
+ override fun createDismissMessagePendingIntent(messageReference: MessageReference): PendingIntent {
+ val intent = NotificationActionService.createDismissMessageIntent(context, messageReference).apply {
+ data = Uri.parse("data:,dismiss/${messageReference.toIdentityString()}")
+ }
+ return PendingIntent.getService(context, 0, intent, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE)
+ }
+
+ override fun createReplyPendingIntent(messageReference: MessageReference): PendingIntent {
+ val intent = MessageActions.getActionReplyIntent(context, messageReference).apply {
+ data = Uri.parse("data:,reply/${messageReference.toIdentityString()}")
+ }
+ return PendingIntent.getActivity(context, 0, intent, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE)
+ }
+
+ override fun createMarkMessageAsReadPendingIntent(messageReference: MessageReference): PendingIntent {
+ val intent = NotificationActionService.createMarkMessageAsReadIntent(context, messageReference).apply {
+ data = Uri.parse("data:,markAsRead/${messageReference.toIdentityString()}")
+ }
+ return PendingIntent.getService(context, 0, intent, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE)
+ }
+
+ override fun createMarkAllAsReadPendingIntent(
+ account: Account,
+ 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 PendingIntent.getService(context, 0, intent, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE)
+ }
+
+ override fun getEditIncomingServerSettingsIntent(account: Account): PendingIntent {
+ val intent = AccountSetupIncoming.intentActionEditIncomingSettings(context, account)
+ return PendingIntent.getActivity(context, account.accountNumber, intent, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE)
+ }
+
+ override fun getEditOutgoingServerSettingsIntent(account: Account): PendingIntent {
+ val intent = AccountSetupOutgoing.intentActionEditOutgoingSettings(context, account)
+ return PendingIntent.getActivity(context, account.accountNumber, intent, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE)
+ }
+
+ 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 PendingIntent.getService(context, 0, intent, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE)
+ }
+
+ private fun createDeleteConfirmationPendingIntent(messageReference: MessageReference): PendingIntent {
+ val intent = DeleteConfirmationActivity.getIntent(context, messageReference).apply {
+ data = Uri.parse("data:,deleteConfirmation/${messageReference.toIdentityString()}")
+ }
+ return PendingIntent.getActivity(context, 0, intent, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE)
+ }
+
+ override fun createDeleteAllPendingIntent(
+ account: Account,
+ 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 PendingIntent.getActivity(context, 0, intent, FLAG_CANCEL_CURRENT or FLAG_IMMUTABLE)
+ }
+
+ private fun getDeleteAllServicePendingIntent(
+ account: Account,
+ 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 PendingIntent.getService(context, 0, intent, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE)
+ }
+
+ override fun createArchiveMessagePendingIntent(messageReference: MessageReference): PendingIntent {
+ val intent = NotificationActionService.createArchiveMessageIntent(context, messageReference).apply {
+ data = Uri.parse("data:,archive/${messageReference.toIdentityString()}")
+ }
+ return PendingIntent.getService(context, 0, intent, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE)
+ }
+
+ override fun createArchiveAllPendingIntent(
+ account: Account,
+ messageReferences: List<MessageReference>
+ ): PendingIntent {
+ val intent = NotificationActionService.createArchiveAllIntent(context, account, messageReferences).apply {
+ data = Uri.parse("data:,archiveAll/${account.uuid}/${System.currentTimeMillis()}")
+ }
+ return PendingIntent.getService(context, 0, intent, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE)
+ }
+
+ override fun createMarkMessageAsSpamPendingIntent(messageReference: MessageReference): PendingIntent {
+ val intent = NotificationActionService.createMarkMessageAsSpamIntent(context, messageReference).apply {
+ data = Uri.parse("data:,spam/${messageReference.toIdentityString()}")
+ }
+ return PendingIntent.getService(context, 0, intent, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE)
+ }
+
+ private fun createMessageListIntent(account: Account): Intent {
+ val folderId = defaultFolderProvider.getDefaultFolder(account)
+ val search = LocalSearch().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: Account, folderId: Long): Intent {
+ val search = LocalSearch().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: Account): Intent {
+ return MessageList.createUnifiedInboxIntent(context, account).apply {
+ data = Uri.parse("data:,unifiedInbox/${account.uuid}")
+ }
+ }
+
+ private fun createNewMessagesIntent(account: Account): 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: Account, 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))
+ }
+}
diff --git a/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationResourceProvider.kt b/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationResourceProvider.kt
new file mode 100644
index 0000000..2c1eea2
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationResourceProvider.kt
@@ -0,0 +1,100 @@
+package com.fsck.k9.notification
+
+import android.content.Context
+import com.fsck.k9.ui.R
+
+class K9NotificationResourceProvider(private val context: Context) : NotificationResourceProvider {
+ override val iconWarning: Int = R.drawable.notification_icon_warning
+ override val iconMarkAsRead: Int = R.drawable.notification_action_mark_as_read
+ override val iconDelete: Int = R.drawable.notification_action_delete
+ override val iconReply: Int = R.drawable.notification_action_reply
+ override val iconNewMail: Int = R.drawable.notification_icon_new_mail
+ override val iconSendingMail: Int = R.drawable.notification_icon_check_mail
+ override val iconCheckingMail: Int = R.drawable.notification_icon_check_mail
+ override val wearIconMarkAsRead: Int = R.drawable.notification_action_mark_as_read
+ override val wearIconDelete: Int = R.drawable.notification_action_delete
+ override val wearIconArchive: Int = R.drawable.notification_action_archive
+ override val wearIconReplyAll: Int = R.drawable.notification_action_reply
+ override val wearIconMarkAsSpam: Int = R.drawable.notification_action_mark_as_spam
+
+ 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 =
+ 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)
+}
diff --git a/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationStrategy.kt b/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationStrategy.kt
new file mode 100644
index 0000000..35b0e9b
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationStrategy.kt
@@ -0,0 +1,102 @@
+package com.fsck.k9.notification
+
+import app.k9mail.core.android.common.contact.ContactRepository
+import app.k9mail.core.common.mail.EmailAddress
+import com.fsck.k9.Account
+import com.fsck.k9.K9
+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.LocalFolder.isModeMismatch
+import com.fsck.k9.mailstore.LocalMessage
+import timber.log.Timber
+
+class K9NotificationStrategy(
+ private val contactRepository: ContactRepository,
+) : NotificationStrategy {
+
+ override fun shouldNotifyForMessage(
+ account: Account,
+ localFolder: LocalFolder,
+ message: LocalMessage,
+ isOldMessage: Boolean
+ ): Boolean {
+ if (!K9.isNotificationDuringQuietTimeEnabled && K9.isQuietTime) {
+ Timber.v("No notification: Quiet time is active")
+ return false
+ }
+
+ if (!account.isNotifyNewMail) {
+ Timber.v("No notification: Notifications are disabled")
+ return false
+ }
+
+ val folder = message.folder
+ if (folder != null) {
+ when (folder.databaseId) {
+ account.inboxFolderId -> {
+ // Don't skip notifications if the Inbox folder is also configured as another special folder
+ }
+ account.trashFolderId -> {
+ Timber.v("No notification: Message is in Trash folder")
+ return false
+ }
+ account.draftsFolderId -> {
+ Timber.v("No notification: Message is in Drafts folder")
+ return false
+ }
+ account.spamFolderId -> {
+ Timber.v("No notification: Message is in Spam folder")
+ return false
+ }
+ account.sentFolderId -> {
+ Timber.v("No notification: Message is in Sent folder")
+ return false
+ }
+ }
+ }
+
+ if (isModeMismatch(account.folderDisplayMode, localFolder.displayClass)) {
+ Timber.v("No notification: Message is in folder not being displayed")
+ return false
+ }
+
+ if (isModeMismatch(account.folderNotifyNewMailMode, localFolder.notifyClass)) {
+ Timber.v("No notification: Notifications are disabled for this folder class")
+ return false
+ }
+
+ if (isOldMessage) {
+ Timber.v("No notification: Message is old")
+ return false
+ }
+
+ if (message.isSet(Flag.SEEN)) {
+ Timber.v("No notification: Message is marked as read")
+ return false
+ }
+
+ if (account.isIgnoreChatMessages && message.isChatMessage) {
+ Timber.v("No notification: Notifications for chat messages are disabled")
+ return false
+ }
+
+ if (!account.isNotifySelfNewMail && account.isAnIdentity(message.from)) {
+ Timber.v("No notification: Notifications for messages from yourself are disabled")
+ return false
+ }
+
+ if (account.isNotifyContactsMailOnly &&
+ !contactRepository.hasAnyContactFor(message.from.asList().mapNotNull { EmailAddress(it.address) })
+ ) {
+ Timber.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()
+}
diff --git a/app/k9mail/src/main/java/com/fsck/k9/notification/KoinModule.kt b/app/k9mail/src/main/java/com/fsck/k9/notification/KoinModule.kt
new file mode 100644
index 0000000..d86c64d
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/notification/KoinModule.kt
@@ -0,0 +1,11 @@
+package com.fsck.k9.notification
+
+import org.koin.dsl.module
+
+val notificationModule = module {
+ single<NotificationActionCreator> {
+ K9NotificationActionCreator(context = get(), defaultFolderProvider = get(), messageStoreManager = get())
+ }
+ single<NotificationResourceProvider> { K9NotificationResourceProvider(get()) }
+ single<NotificationStrategy> { K9NotificationStrategy(get()) }
+}
diff --git a/app/k9mail/src/main/java/com/fsck/k9/provider/UnreadWidgetProvider.kt b/app/k9mail/src/main/java/com/fsck/k9/provider/UnreadWidgetProvider.kt
new file mode 100644
index 0000000..99325bd
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/provider/UnreadWidgetProvider.kt
@@ -0,0 +1,103 @@
+package com.fsck.k9.provider
+
+import android.app.PendingIntent
+import android.app.PendingIntent.FLAG_UPDATE_CURRENT
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProvider
+import android.content.Context
+import android.content.Intent
+import android.view.View
+import android.widget.RemoteViews
+import com.fsck.k9.EarlyInit
+import com.fsck.k9.R
+import com.fsck.k9.helper.PendingIntentCompat.FLAG_MUTABLE
+import com.fsck.k9.inject
+import com.fsck.k9.widget.unread.UnreadWidgetConfigurationActivity
+import com.fsck.k9.widget.unread.UnreadWidgetData
+import com.fsck.k9.widget.unread.UnreadWidgetRepository
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+/**
+ * Unread home screen widget "provider"
+ *
+ * IMPORTANT: This class must not be renamed or moved, otherwise unread widgets added to the home screen using an older
+ * version of the app will stop working.
+ *
+ * The rest of the unread widget specific code can be found in the package [com.fsck.k9.widget.unread].
+ */
+class UnreadWidgetProvider : AppWidgetProvider(), EarlyInit {
+ private val repository: UnreadWidgetRepository by inject()
+
+ override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
+ val pendingResult = goAsync()
+
+ GlobalScope.launch(Dispatchers.IO) {
+ updateWidgets(context, appWidgetManager, appWidgetIds)
+ pendingResult.finish()
+ }
+ }
+
+ private fun updateWidgets(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
+ for (widgetId in appWidgetIds) {
+ val widgetData = repository.getWidgetData(widgetId) ?: continue
+ updateWidget(context, appWidgetManager, widgetData)
+ }
+ }
+
+ override fun onDeleted(context: Context, appWidgetIds: IntArray) {
+ for (appWidgetId in appWidgetIds) {
+ repository.deleteWidgetConfiguration(appWidgetId)
+ }
+ }
+
+ private fun updateWidget(context: Context, appWidgetManager: AppWidgetManager, data: UnreadWidgetData) {
+ val remoteViews = RemoteViews(context.packageName, R.layout.unread_widget_layout)
+
+ val appWidgetId = data.configuration.appWidgetId
+ var clickIntent: Intent? = null
+
+ try {
+ clickIntent = data.clickIntent
+ val unreadCount = data.unreadCount
+
+ if (unreadCount <= 0) {
+ // Hide TextView for unread count if there are no unread messages.
+ remoteViews.setViewVisibility(R.id.unread_count, View.GONE)
+ } else {
+ remoteViews.setViewVisibility(R.id.unread_count, View.VISIBLE)
+
+ val displayCount = if (unreadCount <= MAX_COUNT) unreadCount.toString() else "$MAX_COUNT+"
+ remoteViews.setTextViewText(R.id.unread_count, displayCount)
+ }
+
+ remoteViews.setTextViewText(R.id.title, data.title)
+ } catch (e: Exception) {
+ Timber.e(e, "Error getting widget configuration")
+ }
+
+ if (clickIntent == null) {
+ // If the widget configuration couldn't be loaded we open the configuration
+ // activity when the user clicks the widget.
+ clickIntent = Intent(context, UnreadWidgetConfigurationActivity::class.java)
+ clickIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
+ }
+ clickIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+
+ val pendingIntent = PendingIntent.getActivity(
+ context,
+ appWidgetId,
+ clickIntent,
+ FLAG_UPDATE_CURRENT or FLAG_MUTABLE
+ )
+ remoteViews.setOnClickPendingIntent(R.id.unread_widget_layout, pendingIntent)
+
+ appWidgetManager.updateAppWidget(appWidgetId, remoteViews)
+ }
+
+ companion object {
+ private const val MAX_COUNT = 9999
+ }
+}
diff --git a/app/k9mail/src/main/java/com/fsck/k9/resources/K9AutocryptStringProvider.kt b/app/k9mail/src/main/java/com/fsck/k9/resources/K9AutocryptStringProvider.kt
new file mode 100644
index 0000000..411ad4e
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/resources/K9AutocryptStringProvider.kt
@@ -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)
+}
diff --git a/app/k9mail/src/main/java/com/fsck/k9/resources/K9CoreResourceProvider.kt b/app/k9mail/src/main/java/com/fsck/k9/resources/K9CoreResourceProvider.kt
new file mode 100644
index 0000000..d2ec950
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/resources/K9CoreResourceProvider.kt
@@ -0,0 +1,52 @@
+package com.fsck.k9.resources
+
+import android.content.Context
+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 defaultSignature(): String = context.getString(R.string.default_signature)
+ 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 encryptedSubject(): String = context.getString(R.string.encrypted_subject)
+
+ 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 fun outboxFolderName(): String = context.getString(R.string.special_mailbox_name_outbox)
+
+ override val iconPushNotification: Int = R.drawable.ic_push_notification
+
+ 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
+ }
+ return context.getString(resId)
+ }
+
+ override fun pushNotificationInfoText(): String = context.getString(R.string.push_notification_info)
+}
diff --git a/app/k9mail/src/main/java/com/fsck/k9/resources/KoinModule.kt b/app/k9mail/src/main/java/com/fsck/k9/resources/KoinModule.kt
new file mode 100644
index 0000000..a54e8ec
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/resources/KoinModule.kt
@@ -0,0 +1,10 @@
+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(get()) }
+ single<AutocryptStringProvider> { K9AutocryptStringProvider(get()) }
+}
diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/KoinModule.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/list/KoinModule.kt
new file mode 100644
index 0000000..c7cebde
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/widget/list/KoinModule.kt
@@ -0,0 +1,8 @@
+package com.fsck.k9.widget.list
+
+import app.k9mail.ui.widget.list.MessageListWidgetConfig
+import org.koin.dsl.module
+
+val messageListWidgetConfigModule = module {
+ single<MessageListWidgetConfig> { K9MessageListWidgetConfig() }
+}
diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetProvider.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetProvider.kt
new file mode 100644
index 0000000..805be2e
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/widget/list/MessageListWidgetProvider.kt
@@ -0,0 +1,9 @@
+package com.fsck.k9.widget.list
+
+import app.k9mail.ui.widget.list.MessageListWidgetConfig
+
+class MessageListWidgetProvider : app.k9mail.ui.widget.list.MessageListWidgetProvider()
+
+internal class K9MessageListWidgetConfig : MessageListWidgetConfig {
+ override val providerClass = MessageListWidgetProvider::class.java
+}
diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/unread/KoinModule.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/KoinModule.kt
new file mode 100644
index 0000000..9eea517
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/KoinModule.kt
@@ -0,0 +1,20 @@
+package com.fsck.k9.widget.unread
+
+import org.koin.dsl.module
+
+val unreadWidgetModule = module {
+ single { UnreadWidgetRepository(context = get(), dataRetriever = get(), migrations = get()) }
+ single {
+ UnreadWidgetDataProvider(
+ context = get(),
+ preferences = get(),
+ messageCountsProvider = get(),
+ defaultFolderProvider = get(),
+ folderRepository = get(),
+ folderNameFormatter = get()
+ )
+ }
+ single { UnreadWidgetUpdater(context = get()) }
+ single { UnreadWidgetUpdateListener(unreadWidgetUpdater = get()) }
+ single { UnreadWidgetMigrations(accountRepository = get(), folderRepository = get()) }
+}
diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetConfigurationActivity.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetConfigurationActivity.kt
new file mode 100644
index 0000000..fa1662b
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetConfigurationActivity.kt
@@ -0,0 +1,39 @@
+package com.fsck.k9.widget.unread
+
+import android.appwidget.AppWidgetManager
+import android.os.Bundle
+import com.fsck.k9.R
+import com.fsck.k9.ui.base.K9Activity
+import com.fsck.k9.ui.fragmentTransaction
+import timber.log.Timber
+import com.fsck.k9.ui.R as UiR
+
+/**
+ * Activity to select an account for the unread widget.
+ */
+class UnreadWidgetConfigurationActivity : K9Activity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setLayout(R.layout.activity_unread_widget_configuration)
+ setTitle(UiR.string.unread_widget_select_account)
+
+ var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
+ val extras = intent.extras
+ if (extras != null) {
+ appWidgetId = extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
+ }
+
+ if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
+ Timber.e("Received an invalid widget ID")
+ finish()
+ return
+ }
+
+ if (savedInstanceState == null) {
+ fragmentTransaction {
+ add(R.id.fragment_container, UnreadWidgetConfigurationFragment.create(appWidgetId))
+ }
+ }
+ }
+}
diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetConfigurationFragment.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetConfigurationFragment.kt
new file mode 100644
index 0000000..5c9a19f
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetConfigurationFragment.kt
@@ -0,0 +1,226 @@
+package com.fsck.k9.widget.unread
+
+import android.app.Activity
+import android.appwidget.AppWidgetManager
+import android.content.Intent
+import android.os.Bundle
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.widget.Toast
+import androidx.core.os.bundleOf
+import androidx.preference.CheckBoxPreference
+import androidx.preference.Preference
+import com.fsck.k9.Preferences
+import com.fsck.k9.R
+import com.fsck.k9.activity.ChooseAccount
+import com.fsck.k9.search.SearchAccount
+import com.fsck.k9.ui.choosefolder.ChooseFolderActivity
+import com.takisoft.preferencex.PreferenceFragmentCompat
+import org.koin.android.ext.android.inject
+import com.fsck.k9.ui.R as UiR
+
+class UnreadWidgetConfigurationFragment : PreferenceFragmentCompat() {
+ private val preferences: Preferences by inject()
+ private val repository: UnreadWidgetRepository by inject()
+ private val unreadWidgetUpdater: UnreadWidgetUpdater by inject()
+
+ private var appWidgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID
+ private lateinit var unreadAccount: Preference
+ private lateinit var unreadFolderEnabled: CheckBoxPreference
+ private lateinit var unreadFolder: Preference
+
+ private var selectedAccountUuid: String? = null
+ private var selectedFolderId: Long? = null
+ private var selectedFolderDisplayName: String? = null
+
+ override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) {
+ setHasOptionsMenu(true)
+ setPreferencesFromResource(R.xml.unread_widget_configuration, rootKey)
+
+ appWidgetId = arguments?.getInt(ARGUMENT_APP_WIDGET_ID) ?: error("Missing argument '$ARGUMENT_APP_WIDGET_ID'")
+
+ unreadAccount = findPreference(PREFERENCE_UNREAD_ACCOUNT)!!
+ unreadAccount.onPreferenceClickListener = Preference.OnPreferenceClickListener {
+ val intent = Intent(requireContext(), ChooseAccount::class.java)
+ startActivityForResult(intent, REQUEST_CHOOSE_ACCOUNT)
+ false
+ }
+
+ unreadFolderEnabled = findPreference(PREFERENCE_UNREAD_FOLDER_ENABLED)!!
+ unreadFolderEnabled.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, _ ->
+ unreadFolder.summary = getString(UiR.string.unread_widget_folder_summary)
+ selectedFolderId = null
+ selectedFolderDisplayName = null
+ true
+ }
+
+ unreadFolder = findPreference(PREFERENCE_UNREAD_FOLDER)!!
+ unreadFolder.onPreferenceClickListener = Preference.OnPreferenceClickListener {
+ val intent = ChooseFolderActivity.buildLaunchIntent(
+ context = requireContext(),
+ action = ChooseFolderActivity.Action.CHOOSE,
+ accountUuid = selectedAccountUuid!!,
+ showDisplayableOnly = true
+ )
+ startActivityForResult(intent, REQUEST_CHOOSE_FOLDER)
+ false
+ }
+
+ if (savedInstanceState != null) {
+ restoreInstanceState(savedInstanceState)
+ }
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ outState.putString(STATE_SELECTED_ACCOUNT_UUID, selectedAccountUuid)
+ outState.putLongIfPresent(STATE_SELECTED_FOLDER_ID, selectedFolderId)
+ outState.putString(STATE_SELECTED_FOLDER_DISPLAY_NAME, selectedFolderDisplayName)
+ }
+
+ private fun restoreInstanceState(savedInstanceState: Bundle) {
+ val accountUuid = savedInstanceState.getString(STATE_SELECTED_ACCOUNT_UUID)
+ if (accountUuid != null) {
+ handleChooseAccount(accountUuid)
+ val folderId = savedInstanceState.getLongOrNull(STATE_SELECTED_FOLDER_ID)
+ val folderSummary = savedInstanceState.getString(STATE_SELECTED_FOLDER_DISPLAY_NAME)
+ if (folderId != null && folderSummary != null) {
+ handleChooseFolder(folderId, folderSummary)
+ }
+ }
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ if (resultCode == Activity.RESULT_OK && data != null) {
+ when (requestCode) {
+ REQUEST_CHOOSE_ACCOUNT -> {
+ val accountUuid = data.getStringExtra(ChooseAccount.EXTRA_ACCOUNT_UUID)!!
+ handleChooseAccount(accountUuid)
+ }
+ REQUEST_CHOOSE_FOLDER -> {
+ val folderId = data.getLongExtra(ChooseFolderActivity.RESULT_SELECTED_FOLDER_ID, -1L)
+ val folderDisplayName = data.getStringExtra(ChooseFolderActivity.RESULT_FOLDER_DISPLAY_NAME)!!
+ handleChooseFolder(folderId, folderDisplayName)
+ }
+ }
+ }
+ super.onActivityResult(requestCode, resultCode, data)
+ }
+
+ private fun handleChooseAccount(accountUuid: String) {
+ val userSelectedSameAccount = accountUuid == selectedAccountUuid
+ if (userSelectedSameAccount) {
+ return
+ }
+
+ selectedAccountUuid = accountUuid
+ selectedFolderId = null
+ selectedFolderDisplayName = null
+ unreadFolder.summary = getString(UiR.string.unread_widget_folder_summary)
+ if (SearchAccount.UNIFIED_INBOX == selectedAccountUuid) {
+ handleSearchAccount()
+ } else {
+ handleRegularAccount()
+ }
+ }
+
+ private fun handleSearchAccount() {
+ if (SearchAccount.UNIFIED_INBOX == selectedAccountUuid) {
+ unreadAccount.setSummary(UiR.string.unread_widget_unified_inbox_account_summary)
+ }
+ unreadFolderEnabled.isEnabled = false
+ unreadFolderEnabled.isChecked = false
+ unreadFolder.isEnabled = false
+ selectedFolderId = null
+ selectedFolderDisplayName = null
+ }
+
+ private fun handleRegularAccount() {
+ val selectedAccount = preferences.getAccount(selectedAccountUuid!!)
+ ?: error("Account $selectedAccountUuid not found")
+
+ unreadAccount.summary = selectedAccount.displayName
+ unreadFolderEnabled.isEnabled = true
+ unreadFolder.isEnabled = true
+ }
+
+ private fun handleChooseFolder(folderId: Long, folderDisplayName: String) {
+ selectedFolderId = folderId
+ selectedFolderDisplayName = folderDisplayName
+ unreadFolder.summary = folderDisplayName
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
+ inflater.inflate(R.menu.unread_widget_option, menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ R.id.done -> {
+ if (validateWidget()) {
+ updateWidgetAndExit()
+ }
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ private fun validateWidget(): Boolean {
+ if (selectedAccountUuid == null) {
+ Toast.makeText(requireContext(), UiR.string.unread_widget_account_not_selected, Toast.LENGTH_LONG).show()
+ return false
+ } else if (unreadFolderEnabled.isChecked && selectedFolderId == null) {
+ Toast.makeText(requireContext(), UiR.string.unread_widget_folder_not_selected, Toast.LENGTH_LONG).show()
+ return false
+ }
+ return true
+ }
+
+ private fun updateWidgetAndExit() {
+ val configuration = UnreadWidgetConfiguration(appWidgetId, selectedAccountUuid!!, selectedFolderId)
+ repository.saveWidgetConfiguration(configuration)
+
+ unreadWidgetUpdater.update(appWidgetId)
+
+ // Let the caller know that the configuration was successful
+ val resultValue = Intent()
+ resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
+
+ val activity = requireActivity()
+ activity.setResult(Activity.RESULT_OK, resultValue)
+ activity.finish()
+ }
+
+ private fun Bundle.putLongIfPresent(key: String, value: Long?) {
+ if (value != null) {
+ putLong(key, value)
+ }
+ }
+
+ private fun Bundle.getLongOrNull(key: String): Long? {
+ return if (containsKey(key)) getLong(key) else null
+ }
+
+ companion object {
+ private const val ARGUMENT_APP_WIDGET_ID = "app_widget_id"
+
+ private const val PREFERENCE_UNREAD_ACCOUNT = "unread_account"
+ private const val PREFERENCE_UNREAD_FOLDER_ENABLED = "unread_folder_enabled"
+ private const val PREFERENCE_UNREAD_FOLDER = "unread_folder"
+
+ private const val REQUEST_CHOOSE_ACCOUNT = 1
+ private const val REQUEST_CHOOSE_FOLDER = 2
+
+ private const val STATE_SELECTED_ACCOUNT_UUID = "com.fsck.k9.widget.unread.selectedAccountUuid"
+ private const val STATE_SELECTED_FOLDER_ID = "com.fsck.k9.widget.unread.selectedFolderId"
+ private const val STATE_SELECTED_FOLDER_DISPLAY_NAME = "com.fsck.k9.widget.unread.selectedFolderDisplayName"
+
+ fun create(appWidgetId: Int): UnreadWidgetConfigurationFragment {
+ return UnreadWidgetConfigurationFragment().apply {
+ arguments = bundleOf(ARGUMENT_APP_WIDGET_ID to appWidgetId)
+ }
+ }
+ }
+}
diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetData.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetData.kt
new file mode 100644
index 0000000..bfcfead
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetData.kt
@@ -0,0 +1,10 @@
+package com.fsck.k9.widget.unread
+
+import android.content.Intent
+
+data class UnreadWidgetData(
+ val configuration: UnreadWidgetConfiguration,
+ val title: String,
+ val unreadCount: Int,
+ val clickIntent: Intent
+)
diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetDataProvider.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetDataProvider.kt
new file mode 100644
index 0000000..572887d
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetDataProvider.kt
@@ -0,0 +1,98 @@
+package com.fsck.k9.widget.unread
+
+import android.content.Context
+import android.content.Intent
+import com.fsck.k9.Account
+import com.fsck.k9.Preferences
+import com.fsck.k9.activity.MessageList
+import com.fsck.k9.controller.MessageCountsProvider
+import com.fsck.k9.mailstore.FolderRepository
+import com.fsck.k9.search.LocalSearch
+import com.fsck.k9.search.SearchAccount
+import com.fsck.k9.ui.folders.FolderNameFormatter
+import com.fsck.k9.ui.messagelist.DefaultFolderProvider
+import timber.log.Timber
+import com.fsck.k9.ui.R as UiR
+
+class UnreadWidgetDataProvider(
+ private val context: Context,
+ private val preferences: Preferences,
+ private val messageCountsProvider: MessageCountsProvider,
+ private val defaultFolderProvider: DefaultFolderProvider,
+ private val folderRepository: FolderRepository,
+ private val folderNameFormatter: FolderNameFormatter
+) {
+ fun loadUnreadWidgetData(configuration: UnreadWidgetConfiguration): UnreadWidgetData? = with(configuration) {
+ if (SearchAccount.UNIFIED_INBOX == accountUuid) {
+ loadSearchAccountData(configuration)
+ } else if (folderId != null) {
+ loadFolderData(configuration)
+ } else {
+ loadAccountData(configuration)
+ }
+ }
+
+ private fun loadSearchAccountData(configuration: UnreadWidgetConfiguration): UnreadWidgetData {
+ val searchAccount = getSearchAccount(configuration.accountUuid)
+ val title = searchAccount.name
+ val unreadCount = messageCountsProvider.getMessageCounts(searchAccount).unread
+ val clickIntent = MessageList.intentDisplaySearch(context, searchAccount.relatedSearch, false, true, true)
+
+ return UnreadWidgetData(configuration, title, unreadCount, clickIntent)
+ }
+
+ private fun getSearchAccount(accountUuid: String): SearchAccount = when (accountUuid) {
+ SearchAccount.UNIFIED_INBOX -> SearchAccount.createUnifiedInboxAccount()
+ else -> throw AssertionError("SearchAccount expected")
+ }
+
+ private fun loadAccountData(configuration: UnreadWidgetConfiguration): UnreadWidgetData? {
+ val account = preferences.getAccount(configuration.accountUuid) ?: return null
+ val title = account.displayName
+ val unreadCount = messageCountsProvider.getMessageCounts(account).unread
+ val clickIntent = getClickIntentForAccount(account)
+
+ return UnreadWidgetData(configuration, title, unreadCount, clickIntent)
+ }
+
+ private fun getClickIntentForAccount(account: Account): Intent {
+ val folderId = defaultFolderProvider.getDefaultFolder(account)
+ return getClickIntentForFolder(account, folderId)
+ }
+
+ private fun loadFolderData(configuration: UnreadWidgetConfiguration): UnreadWidgetData? {
+ val accountUuid = configuration.accountUuid
+ val account = preferences.getAccount(accountUuid) ?: return null
+ val folderId = configuration.folderId ?: return null
+
+ val accountName = account.displayName
+ val folderDisplayName = getFolderDisplayName(account, folderId)
+ val title = context.getString(UiR.string.unread_widget_title, accountName, folderDisplayName)
+
+ val unreadCount = messageCountsProvider.getUnreadMessageCount(account, folderId)
+
+ val clickIntent = getClickIntentForFolder(account, folderId)
+
+ return UnreadWidgetData(configuration, title, unreadCount, clickIntent)
+ }
+
+ private fun getFolderDisplayName(account: Account, folderId: Long): String {
+ val folder = folderRepository.getFolder(account, folderId)
+ return if (folder != null) {
+ folderNameFormatter.displayName(folder)
+ } else {
+ Timber.e("Error loading folder for account %s, folder ID: %d", account, folderId)
+ ""
+ }
+ }
+
+ private fun getClickIntentForFolder(account: Account, folderId: Long): Intent {
+ val search = LocalSearch()
+ search.addAllowedFolder(folderId)
+ search.addAccountUuid(account.uuid)
+
+ val clickIntent = MessageList.intentDisplaySearch(context, search, false, true, true)
+ clickIntent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
+ return clickIntent
+ }
+}
diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetMigrations.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetMigrations.kt
new file mode 100644
index 0000000..4221cce
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetMigrations.kt
@@ -0,0 +1,44 @@
+package com.fsck.k9.widget.unread
+
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import com.fsck.k9.Preferences
+import com.fsck.k9.mailstore.FolderRepository
+import com.fsck.k9.widget.unread.UnreadWidgetRepository.Companion.PREFS_VERSION
+import com.fsck.k9.widget.unread.UnreadWidgetRepository.Companion.PREF_VERSION_KEY
+
+internal class UnreadWidgetMigrations(
+ private val accountRepository: Preferences,
+ private val folderRepository: FolderRepository
+) {
+ fun upgradePreferences(preferences: SharedPreferences, version: Int) {
+ if (version < 2) rewriteFolderNameToFolderId(preferences)
+
+ preferences.setVersion(PREFS_VERSION)
+ }
+
+ private fun SharedPreferences.setVersion(version: Int) {
+ edit { putInt(PREF_VERSION_KEY, version) }
+ }
+
+ private fun rewriteFolderNameToFolderId(preferences: SharedPreferences) {
+ val widgetIds = preferences.all.keys
+ .filter { it.endsWith(".folder_name") }
+ .map { it.split(".")[1] }
+
+ preferences.edit {
+ for (widgetId in widgetIds) {
+ val accountUuid = preferences.getString("unread_widget.$widgetId", null) ?: continue
+ val account = accountRepository.getAccount(accountUuid) ?: continue
+
+ val folderServerId = preferences.getString("unread_widget.$widgetId.folder_name", null)
+ if (folderServerId != null) {
+ val folderId = folderRepository.getFolderId(account, folderServerId)
+ putString("unread_widget.$widgetId.folder_id", folderId?.toString())
+ }
+
+ remove("unread_widget.$widgetId.folder_name")
+ }
+ }
+ }
+}
diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetRepository.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetRepository.kt
new file mode 100644
index 0000000..07152b7
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetRepository.kt
@@ -0,0 +1,62 @@
+package com.fsck.k9.widget.unread
+
+import android.content.Context
+import android.content.SharedPreferences
+
+internal class UnreadWidgetRepository(
+ private val context: Context,
+ private val dataRetriever: UnreadWidgetDataProvider,
+ private val migrations: UnreadWidgetMigrations
+) {
+
+ fun saveWidgetConfiguration(configuration: UnreadWidgetConfiguration) {
+ val appWidgetId = configuration.appWidgetId
+ val editor = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
+ editor.putString(PREF_PREFIX_KEY + appWidgetId, configuration.accountUuid)
+ editor.putString(PREF_PREFIX_KEY + appWidgetId + PREF_FOLDER_ID_SUFFIX_KEY, configuration.folderId?.toString())
+ editor.apply()
+ }
+
+ fun getWidgetData(appWidgetId: Int): UnreadWidgetData? {
+ val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+
+ val version = prefs.getInt(PREF_VERSION_KEY, 1)
+ if (version != PREFS_VERSION) {
+ upgradePreferences(version, prefs)
+ }
+
+ val accountUuid = prefs.getString(PREF_PREFIX_KEY + appWidgetId, null) ?: return null
+ val folderId = prefs.getString(PREF_PREFIX_KEY + appWidgetId + PREF_FOLDER_ID_SUFFIX_KEY, null)?.toLongOrNull()
+
+ val configuration = UnreadWidgetConfiguration(appWidgetId, accountUuid, folderId)
+
+ return dataRetriever.loadUnreadWidgetData(configuration)
+ }
+
+ private fun upgradePreferences(version: Int, preferences: SharedPreferences) {
+ if (version > PREFS_VERSION) {
+ error("UnreadWidgetRepository: Version downgrades are not supported")
+ } else {
+ migrations.upgradePreferences(preferences, version)
+ }
+ }
+
+ fun deleteWidgetConfiguration(appWidgetId: Int) {
+ val editor = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
+ editor.remove(PREF_PREFIX_KEY + appWidgetId)
+ editor.remove(PREF_PREFIX_KEY + appWidgetId + PREF_FOLDER_ID_SUFFIX_KEY)
+ editor.apply()
+ }
+
+ companion object {
+ internal const val PREFS_VERSION = 2
+ internal const val PREF_VERSION_KEY = "version"
+
+ private const val PREFS_NAME = "unread_widget_configuration.xml"
+
+ private const val PREF_PREFIX_KEY = "unread_widget."
+ private const val PREF_FOLDER_ID_SUFFIX_KEY = ".folder_id"
+ }
+}
+
+data class UnreadWidgetConfiguration(val appWidgetId: Int, val accountUuid: String, val folderId: Long?)
diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetUpdateListener.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetUpdateListener.kt
new file mode 100644
index 0000000..2904e0f
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetUpdateListener.kt
@@ -0,0 +1,29 @@
+package com.fsck.k9.widget.unread
+
+import com.fsck.k9.Account
+import com.fsck.k9.controller.SimpleMessagingListener
+import com.fsck.k9.mail.Message
+import timber.log.Timber
+
+class UnreadWidgetUpdateListener(private val unreadWidgetUpdater: UnreadWidgetUpdater) : SimpleMessagingListener() {
+
+ private fun updateUnreadWidget() {
+ try {
+ unreadWidgetUpdater.updateAll()
+ } catch (e: Exception) {
+ Timber.e(e, "Error while updating unread widget(s)")
+ }
+ }
+
+ override fun synchronizeMailboxRemovedMessage(account: Account, folderServerId: String, messageServerId: String) {
+ updateUnreadWidget()
+ }
+
+ override fun synchronizeMailboxNewMessage(account: Account, folderServerId: String, message: Message) {
+ updateUnreadWidget()
+ }
+
+ override fun folderStatusChanged(account: Account, folderId: Long) {
+ updateUnreadWidget()
+ }
+}
diff --git a/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetUpdater.kt b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetUpdater.kt
new file mode 100644
index 0000000..b86f115
--- /dev/null
+++ b/app/k9mail/src/main/java/com/fsck/k9/widget/unread/UnreadWidgetUpdater.kt
@@ -0,0 +1,30 @@
+package com.fsck.k9.widget.unread
+
+import android.appwidget.AppWidgetManager
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import com.fsck.k9.provider.UnreadWidgetProvider
+
+class UnreadWidgetUpdater(private val context: Context) {
+ private val appWidgetManager = AppWidgetManager.getInstance(context)
+
+ fun updateAll() {
+ val thisWidget = ComponentName(context, UnreadWidgetProvider::class.java)
+ val widgetIds = appWidgetManager.getAppWidgetIds(thisWidget)
+
+ updateWidgets(context, widgetIds)
+ }
+
+ fun update(widgetId: Int) {
+ updateWidgets(context, intArrayOf(widgetId))
+ }
+
+ private fun updateWidgets(context: Context, widgetIds: IntArray) {
+ val updateIntent = Intent(context, UnreadWidgetProvider::class.java)
+ updateIntent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
+ updateIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, widgetIds)
+
+ context.sendBroadcast(updateIntent)
+ }
+}