diff options
| author | test@dangofactory.cos <you@example.com> | 2026-08-20 16:57:18 +0000 |
|---|---|---|
| committer | test@dangofactory.cos <you@example.com> | 2026-08-20 16:57:18 +0000 |
| commit | 50ce5d5e6cd0d720ff437d35eace545816848428 (patch) | |
| tree | b59af206a36265cf081945e434900b32b0c43edd /app/k9mail/src/main/java | |
| download | monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.gz monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.bz2 monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.zip | |
Inital
Diffstat (limited to 'app/k9mail/src/main/java')
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) + } +} |
