Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-24 18:55:42 +01:00
parent a629de6271
commit 3cef7c5092
2161 changed files with 246605 additions and 2 deletions

170
app/k9mail/build.gradle.kts Normal file
View file

@ -0,0 +1,170 @@
plugins {
id(ThunderbirdPlugins.App.android)
}
val testCoverageEnabled: Boolean by extra
if (testCoverageEnabled) {
apply(plugin = "jacoco")
}
dependencies {
implementation(projects.app.ui.legacy)
implementation(projects.app.ui.messageListWidget)
implementation(projects.app.core)
implementation(projects.app.storage)
implementation(projects.app.cryptoOpenpgp)
implementation(projects.backend.imap)
implementation(projects.backend.pop3)
implementation(projects.backend.webdav)
debugImplementation(projects.backend.demo)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.work.ktx)
implementation(libs.preferencex)
implementation(libs.timber)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.glide)
annotationProcessor(libs.glide.compiler)
if (project.hasProperty("k9mail.enableLeakCanary") && project.property("k9mail.enableLeakCanary") == "true") {
debugImplementation(libs.leakcanary.android)
}
// Required for DependencyInjectionTest to be able to resolve OpenPgpApiManager
testImplementation(projects.plugins.openpgpApiLib.openpgpApi)
testImplementation(libs.robolectric)
}
android {
namespace = "com.fsck.k9"
defaultConfig {
applicationId = "de.monocles.mail"
testApplicationId = "de.monocles.mail"
versionCode = 12
versionName = "1.2.3"
// Keep in sync with the resource string array "supported_languages"
resourceConfigurations.addAll(
listOf(
"in", "br", "ca", "cs", "cy", "da", "de", "et", "en", "en_GB", "es", "eo", "eu", "fr", "gd", "gl",
"hr", "is", "it", "lv", "lt", "hu", "nl", "nb", "pl", "pt_PT", "pt_BR", "ru", "ro", "sq", "sk", "sl",
"fi", "sv", "tr", "el", "be", "bg", "sr", "uk", "iw", "ar", "fa", "ml", "ko", "zh_CN", "zh_TW", "ja",
"fy",
),
)
}
signingConfigs {
if (project.hasProperty("k9mail.keyAlias") &&
project.hasProperty("k9mail.keyPassword") &&
project.hasProperty("k9mail.storeFile") &&
project.hasProperty("k9mail.storePassword")
) {
create("release") {
keyAlias = project.property("k9mail.keyAlias") as String
keyPassword = project.property("k9mail.keyPassword") as String
storeFile = file(project.property("k9mail.storeFile") as String)
storePassword = project.property("k9mail.storePassword") as String
}
}
}
buildTypes {
release {
signingConfigs.findByName("release")?.let { releaseSigningConfig ->
signingConfig = releaseSigningConfig
}
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android.txt"),
"proguard-rules.pro",
)
buildConfigField(
"String",
"OAUTH_GMAIL_CLIENT_ID",
"\"262622259280-hhmh92rhklkg2k1tjil69epo0o9a12jm.apps.googleusercontent.com\"",
)
buildConfigField(
"String",
"OAUTH_YAHOO_CLIENT_ID",
"\"dj0yJmk9aHNUb3d2MW5TQnpRJmQ9WVdrOWVYbHpaRWM0YkdnbWNHbzlNQT09JnM9Y29uc3VtZXJzZWNyZXQmc3Y9MCZ4PWIz\"",
)
buildConfigField(
"String",
"OAUTH_AOL_CLIENT_ID",
"\"dj0yJmk9dUNqYXZhYWxOYkdRJmQ9WVdrOU1YQnZVRFZoY1ZrbWNHbzlNQT09JnM9Y29uc3VtZXJzZWNyZXQmc3Y9MCZ4PWIw\"",
)
buildConfigField("String", "OAUTH_MICROSOFT_CLIENT_ID", "\"e647013a-ada4-4114-b419-e43d250f99c5\"")
buildConfigField(
"String",
"OAUTH_MICROSOFT_REDIRECT_URI",
"\"msauth://com.fsck.k9/Dx8yUsuhyU3dYYba1aA16Wxu5eM%3D\"",
)
manifestPlaceholders["appAuthRedirectScheme"] = "com.fsck.k9"
}
debug {
applicationIdSuffix = ".debug"
enableUnitTestCoverage = testCoverageEnabled
enableAndroidTestCoverage = testCoverageEnabled
isMinifyEnabled = false
buildConfigField(
"String",
"OAUTH_GMAIL_CLIENT_ID",
"\"262622259280-5qb3vtj68d5dtudmaif4g9vd3cpar8r3.apps.googleusercontent.com\"",
)
buildConfigField(
"String",
"OAUTH_YAHOO_CLIENT_ID",
"\"dj0yJmk9ejRCRU1ybmZjQlVBJmQ9WVdrOVVrZEViak4xYmxZbWNHbzlNQT09JnM9Y29uc3VtZXJzZWNyZXQmc3Y9MCZ4PTZj\"",
)
buildConfigField(
"String",
"OAUTH_AOL_CLIENT_ID",
"\"dj0yJmk9cHYydkJkTUxHcXlYJmQ9WVdrOWVHZHhVVXN4VVV3bWNHbzlNQT09JnM9Y29uc3VtZXJzZWNyZXQmc3Y9MCZ4PTdm\"",
)
buildConfigField("String", "OAUTH_MICROSOFT_CLIENT_ID", "\"e647013a-ada4-4114-b419-e43d250f99c5\"")
buildConfigField(
"String",
"OAUTH_MICROSOFT_REDIRECT_URI",
"\"msauth://com.fsck.k9.debug/VZF2DYuLYAu4TurFd6usQB2JPts%3D\"",
)
manifestPlaceholders["appAuthRedirectScheme"] = "com.fsck.k9.debug"
}
}
packagingOptions {
jniLibs {
excludes += listOf("kotlin/**")
}
resources {
excludes += listOf(
"META-INF/DEPENDENCIES",
"META-INF/LICENSE",
"META-INF/LICENSE.txt",
"META-INF/NOTICE",
"META-INF/NOTICE.txt",
"META-INF/README",
"META-INF/README.md",
"META-INF/CHANGES",
"LICENSE.txt",
"META-INF/*.kotlin_module",
"META-INF/*.version",
"kotlin/**",
"DebugProbesKt.bin",
)
}
}
}

55
app/k9mail/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,55 @@
# Add project specific ProGuard rules here.
-dontobfuscate
# Preserve the line number information for debugging stack traces.
-keepattributes SourceFile,LineNumberTable
# Library specific rules
-dontnote android.net.http.*
-dontnote org.apache.commons.codec.**
-dontnote org.apache.http.**
-dontnote com.squareup.moshi.**
-dontnote com.github.amlcurran.showcaseview.**
-dontnote de.cketti.safecontentresolver.**
-dontnote com.tokenautocomplete.**
-dontwarn okio.**
-dontwarn com.squareup.moshi.**
# Glide
-keep public class * extends com.bumptech.glide.module.AppGlideModule
-keep public class * extends com.bumptech.glide.module.LibraryGlideModule
-keep public enum com.bumptech.glide.load.ImageHeaderParser$** {
**[] $VALUES;
public *;
}
# Project specific rules
-dontnote com.fsck.k9.ui.messageview.**
-dontnote com.fsck.k9.view.**
-assumevalues class * extends android.view.View {
boolean isInEditMode() return false;
}
-keep public class org.openintents.openpgp.**
-keepclassmembers class * extends androidx.appcompat.widget.SearchView {
public <init>(android.content.Context);
}
# okhttp rules
# see: https://github.com/square/okhttp/blob/master/okhttp/src/main/resources/META-INF/proguard/okhttp3.pro
# JSR 305 annotations are for embedding nullability information.
-dontwarn javax.annotation.**
# A resource is loaded with a relative path so the package of this class must be preserved.
-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase
# Animal Sniffer compileOnly dependency to ensure APIs are compatible with older versions of Java.
-dontwarn org.codehaus.mojo.animal_sniffer.*
# OkHttp platform used only on JVM and when Conscrypt dependency is available.
-dontwarn okhttp3.internal.platform.ConscryptPlatform

View file

@ -0,0 +1,12 @@
package app.k9mail.dev
import org.koin.core.module.Module
import org.koin.core.scope.Scope
fun Scope.developmentBackends() = mapOf(
"demo" to get<DemoBackendFactory>()
)
fun Module.developmentModuleAdditions() {
single { DemoBackendFactory(backendStorageFactory = get()) }
}

View file

@ -0,0 +1,14 @@
package app.k9mail.dev
import app.k9mail.backend.demo.DemoBackend
import com.fsck.k9.Account
import com.fsck.k9.backend.BackendFactory
import com.fsck.k9.backend.api.Backend
import com.fsck.k9.mailstore.K9BackendStorageFactory
class DemoBackendFactory(private val backendStorageFactory: K9BackendStorageFactory) : BackendFactory {
override fun createBackend(account: Account): Backend {
val backendStorage = backendStorageFactory.createBackendStorage(account)
return DemoBackend(backendStorage)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -0,0 +1,428 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false"/>
<supports-screens
android:anyDensity="true"
android:largeScreens="true"
android:normalScreens="true"
android:smallScreens="true"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<application
android:name="com.fsck.k9.App"
android:allowTaskReparenting="false"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
android:icon="@drawable/ic_launcher"
android:label="@string/short_app_name"
android:theme="@style/Theme.K9.Startup"
android:resizeableActivity="true"
android:allowBackup="false"
android:supportsRtl="true"
tools:replace="android:theme"
tools:ignore="UnusedAttribute">
<meta-data
android:name="android.app.default_searchable"
android:value="com.fsck.k9.activity.Search"/>
<!-- TODO: Remove once minSdkVersion has been changed to 24+ -->
<meta-data
android:name="com.lge.support.SPLIT_WINDOW"
android:value="true"/>
<uses-library
android:name="com.sec.android.app.multiwindow"
android:required="false"/>
<meta-data
android:name="com.sec.android.support.multiwindow"
android:value="true"/>
<meta-data
android:name="com.samsung.android.sdk.multiwindow.penwindow.enable"
android:value="true"/>
<meta-data android:name="android.webkit.WebView.MetricsOptOut"
android:value="true" />
<activity
android:name=".ui.onboarding.OnboardingActivity"
android:label="@string/welcome_message_title" />
<activity
android:name=".ui.settings.account.OpenPgpAppSelectDialog"
android:configChanges="locale"
android:theme="@style/Theme.K9.Dialog.Translucent.DayNight"
/>
<activity
android:name=".activity.setup.AccountSetupBasics"
android:configChanges="locale"
android:label="@string/account_setup_basics_title"/>
<activity
android:name=".activity.setup.AccountSetupAccountType"
android:configChanges="locale"
android:label="@string/account_setup_account_type_title"/>
<activity
android:name=".activity.setup.AccountSetupIncoming"
android:configChanges="locale"
android:label="@string/account_setup_incoming_title"/>
<activity
android:name=".activity.setup.AccountSetupComposition"
android:configChanges="locale"
android:label="@string/account_settings_composition_title"/>
<activity
android:name=".activity.setup.AccountSetupOutgoing"
android:configChanges="locale"
android:label="@string/account_setup_outgoing_title"/>
<activity
android:name=".activity.setup.AccountSetupOptions"
android:configChanges="locale"
android:label="@string/account_setup_options_title"/>
<activity
android:name=".activity.setup.AccountSetupNames"
android:configChanges="locale"
android:label="@string/account_setup_names_title"/>
<activity
android:name=".activity.ChooseAccount"
android:configChanges="locale"
android:label="@string/choose_account_title"
android:noHistory="true" />
<activity
android:name=".ui.choosefolder.ChooseFolderActivity"
android:configChanges="locale"
android:label="@string/choose_folder_title"
android:noHistory="true" />
<activity
android:name=".activity.ChooseIdentity"
android:configChanges="locale"
android:label="@string/choose_identity_title" />
<activity
android:name=".activity.ManageIdentities"
android:configChanges="locale"
android:label="@string/manage_identities_title"/>
<activity
android:name=".activity.EditIdentity"
android:configChanges="locale"
android:label="@string/edit_identity_title"/>
<activity
android:name=".ui.notification.DeleteConfirmationActivity"
android:excludeFromRecents="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/Theme.K9.Dialog.Translucent.DayNight"/>
<!-- XXX Note: this activity is hacked to ignore config changes,
since it doesn't currently handle them correctly in code. -->
<activity
android:name=".activity.setup.AccountSetupCheckSettings"
android:configChanges="keyboardHidden|orientation|locale"
android:label="@string/account_setup_check_settings_title"/>
<activity
android:name=".ui.endtoend.AutocryptKeyTransferActivity"
android:configChanges="locale"
android:label="@string/ac_transfer_title"
/>
<activity
android:name=".activity.MessageList"
android:launchMode="singleTop"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.LAUNCHER"/>
<category android:name="android.intent.category.APP_EMAIL"/>
<!-- TODO: Remove once minSdkVersion has been changed to 24+ -->
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER"/>
<category android:name="android.intent.category.PENWINDOW_LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<data
android:host="messages"
android:scheme="k9mail"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</activity>
<!--
This component is disabled by default. It will be enabled programmatically after an account has been set up.
-->
<activity
android:name=".activity.MessageCompose"
android:configChanges="locale"
android:enabled="false"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.SENDTO"/>
<data android:scheme="mailto"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<data android:mimeType="*/*"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE"/>
<data android:mimeType="*/*"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="mailto"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
</intent-filter>
<intent-filter>
<action android:name="org.autocrypt.PEER_ACTION"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</activity>
<!-- Search Activity - searchable -->
<activity
android:name=".activity.Search"
android:configChanges="locale"
android:label="@string/search_action"
android:uiOptions="splitActionBarWhenNarrow"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.SEARCH"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
<meta-data
android:name="android.app.searchable"
android:resource="@xml/searchable"/>
</activity>
<!--
This component is disabled by default. It will be enabled programmatically after an account has been set up.
-->
<activity
android:name=".activity.LauncherShortcuts"
android:configChanges="locale"
android:label="@string/shortcuts_title"
android:enabled="false"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.CREATE_SHORTCUT"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</activity>
<activity
android:name=".widget.unread.UnreadWidgetConfigurationActivity"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE"/>
</intent-filter>
</activity>
<activity
android:name=".activity.UpgradeDatabases"
android:label="@string/upgrade_databases_title"/>
<activity
android:name=".ui.managefolders.ManageFoldersActivity"
android:label="@string/folders_action" />
<activity
android:name=".ui.settings.SettingsActivity"
android:label="@string/prefs_title" />
<activity
android:name=".ui.settings.general.GeneralSettingsActivity"
android:label="@string/general_settings_title" />
<activity
android:name=".ui.settings.account.AccountSettingsActivity"
android:label="@string/account_settings_title_fmt" />
<activity
android:name=".ui.messagesource.MessageSourceActivity"
android:label="@string/show_headers_action" />
<activity
android:name=".ui.changelog.RecentChangesActivity"
android:label="@string/changelog_recent_changes_title" />
<activity
android:name=".ui.push.PushInfoActivity"
android:excludeFromRecents="true"
android:exported="false"
android:label="@string/push_info_title"
android:taskAffinity="${applicationId}.push_info">
<intent-filter>
<action android:name="app.k9mail.action.PUSH_INFO" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".activity.setup.OAuthFlowActivity"
android:label="@string/account_setup_basics_title" />
<!-- This component is disabled by default (if possible). It will be enabled programmatically if necessary. -->
<receiver
android:name=".provider.UnreadWidgetProvider"
android:icon="@drawable/ic_launcher"
android:label="@string/unread_widget_label"
android:enabled="@bool/home_screen_widgets_enabled"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/unread_widget_info"/>
</receiver>
<!-- This component is disabled by default (if possible). It will be enabled programmatically if necessary. -->
<receiver
android:name=".widget.list.MessageListWidgetProvider"
android:icon="@drawable/message_list_widget_preview"
android:label="@string/mail_list_widget_text"
android:enabled="@bool/home_screen_widgets_enabled"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/message_list_widget_info" />
</receiver>
<!-- This component is disabled by default. It will be enabled programmatically if necessary. -->
<receiver
android:name=".controller.push.BootCompleteReceiver"
android:exported="false"
android:enabled="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>
</receiver>
<service
android:name=".notification.NotificationActionService"/>
<service
android:name=".service.DatabaseUpgradeService"
android:exported="false"/>
<service
android:name="com.fsck.k9.account.AccountRemoverService"
android:permission="android.permission.BIND_JOB_SERVICE"/>
<service
android:name=".controller.push.PushService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<provider
android:name=".provider.AttachmentProvider"
android:authorities="${applicationId}.attachmentprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="de.cketti.safecontentresolver.ALLOW_INTERNAL_ACCESS"
android:value="true" />
</provider>
<provider
android:name=".provider.RawMessageProvider"
android:authorities="${applicationId}.rawmessageprovider"
android:exported="false">
<meta-data
android:name="de.cketti.safecontentresolver.ALLOW_INTERNAL_ACCESS"
android:value="true" />
</provider>
<provider
android:name=".provider.DecryptedFileProvider"
android:authorities="${applicationId}.decryptedfileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/decrypted_file_provider_paths" />
</provider>
<provider
android:name=".provider.AttachmentTempFileProvider"
android:authorities="${applicationId}.tempfileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/temp_file_provider_paths" />
</provider>
<activity
android:name="net.openid.appauth.RedirectUriReceiverActivity"
android:exported="true"
tools:node="merge">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- Microsoft uses a special redirect URI format for Android apps -->
<data android:scheme="msauth" android:host="${applicationId}"/>
</intent-filter>
</activity>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<!-- We're using on-demand initialization for WorkManager -->
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View file

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

View file

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

View file

@ -0,0 +1,5 @@
package com.fsck.k9
import com.fsck.k9.controller.MessagingListener
class MessagingListenerProvider(val listeners: List<MessagingListener>)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,8 @@
package com.fsck.k9.glide;
import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.module.AppGlideModule;
@GlideModule
public class K9AppGlideModule extends AppGlideModule {
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,10 @@
package com.fsck.k9.resources
import android.content.Context
import com.fsck.k9.autocrypt.AutocryptStringProvider
import com.fsck.k9.ui.R
class K9AutocryptStringProvider(private val context: Context) : AutocryptStringProvider {
override fun transferMessageSubject(): String = context.getString(R.string.ac_transfer_msg_subject)
override fun transferMessageBody(): String = context.getString(R.string.ac_transfer_msg_body)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,011 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1,17 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="120dp"
android:height="120dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:scaleX="0.28915662"
android:scaleY="0.28915662"
android:translateX="30.24"
android:translateY="38.246574">
<path
android:pathData="m156.24,1.29 l-0.16,-0.19 -0.09,0.07a14.17,14.17 0,0 0,-5.65 -1.17L14.01,0a14.22,14.22 0,0 0,-5.66 1.17l-0.09,-0.07 -0.15,0.19a13.61,13.61 0,0 0,-8.09 12.34v81.7a13.82,13.82 0,0 0,14 13.63h136.32a13.83,13.83 0,0 0,14 -13.63v-81.7a13.61,13.61 0,0 0,-8.1 -12.34zM145.04,9.85 L82.19,59.62 19.32,9.85zM150.34,99.11L14.01,99.11a3.84,3.84 0,0 1,-3.88 -3.78v-80.06l72.06,57.05 72,-57v80.05a3.84,3.84 0,0 1,-3.85 3.71z"
android:fillColor="#7189c5"/>
<path
android:pathData="M68.73,67.62A31.33,31.33 0,0 0,59.56 71.08a48.9,48.9 0,0 0,-7.21 4.81c-4.28,3.4 -16.23,11.93 -28.73,12.23a42.87,42.87 0,0 1,-22.2 -5.74,9.77 9.77,0 0,1 -1.42,-1 12.4,12.4 0,0 0,2.87 7.95c6.25,7.51 16.24,16.34 29,17.06 12.62,0.71 20.54,-0.94 32.22,-6.48 10.27,-4.88 16.21,-14.08 18,-21.08h0.09c1.8,7 7.74,16.2 18,21.08 11.68,5.54 19.6,7.19 32.22,6.48 12.62,-0.71 22.69,-9.48 29,-17a12.59,12.59 0,0 0,2.92 -8.08v0a11.19,11.19 0,0 1,-1.7 1.2,42.85 42.85,0 0,1 -21.9,5.58c-12.5,-0.3 -24.45,-8.83 -28.73,-12.23a48.9,48.9 0,0 0,-7.21 -4.81,31.33 31.33,0 0,0 -9.15,-3.46c-2.75,-0.47 -5.26,-0.81 -7.71,-0.16a11,11 0,0 0,-5 3,3.56 3.56,0 0,0 -0.77,1.16 3.75,3.75 0,0 0,-0.78 -1.16,11.09 11.09,0 0,0 -5,-3c-2.38,-0.62 -4.89,-0.28 -7.64,0.19z"
android:fillColor="#3e5ea2"/>
</group>
</vector>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#ffcc0000"/>
<corners android:radius="17dp"/>
</shape>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<!-- FIXME: find a nicer looking way than using 'menuitem_background' -->
<item android:drawable="@android:drawable/menuitem_background"
android:state_pressed="true" />
<item android:drawable="@android:drawable/menuitem_background"
android:state_focused="true"
android:state_enabled="true"
android:state_window_focused="true" />
<item android:drawable="@android:color/transparent" />
</selector>

View file

@ -0,0 +1,6 @@
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_unread_widget_selected" android:state_pressed="true" />
<item android:drawable="@drawable/ic_unread_widget_selected" android:state_focused="true" />
<item android:drawable="@drawable/ic_unread_widget_selected" android:state_selected="true" />
<item android:drawable="@drawable/ic_unread_widget" />
</selector>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".widget.unread.UnreadWidgetConfigurationActivity">
<include layout="@layout/toolbar" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/fragment_container"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>

View file

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
style="@style/UnreadWidgetContainer"
android:id="@+id/unread_widget_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:clickable="true"
android:focusable="true">
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:scaleType="fitCenter"
android:src="@drawable/unread_widget_icon" />
<TextView
android:id="@+id/unread_count"
android:visibility="gone"
android:textSize="12dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|bottom"
android:paddingTop="0.5dp"
android:paddingBottom="0.5dp"
android:paddingLeft="5dp"
android:paddingRight="5dp"
android:background="@drawable/unread_count_background"
android:textColor="#ffffff"
tools:ignore="SpUsage"/>
</FrameLayout>
<TextView
style="@style/UnreadWidgetTextView"
android:id="@+id/title"
android:text="@string/app_name"
android:ellipsize="marquee"
android:paddingTop="1dp"
android:paddingBottom="1dp"
android:paddingLeft="4dp"
android:paddingRight="4dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="3dp"
android:singleLine="true" />
</LinearLayout>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/done"
android:title="@string/unread_widget_action_done"
app:showAsAction="always"
android:icon="?attr/iconActionSave"
/>
</menu>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="UnreadWidgetContainer">
<item name="android:paddingTop">2dp</item>
<item name="android:paddingLeft">2dp</item>
<item name="android:paddingRight">2dp</item>
<item name="android:paddingBottom">0dp</item>
<item name="android:background">@drawable/unread_widget_background</item>
<item name="android:gravity">bottom|center_horizontal</item>
</style>
<style name="UnreadWidgetTextView">
<item name="android:textSize" tools:ignore="SpUsage">13dp</item>
<item name="android:background">@drawable/rounded_corners</item>
<item name="android:textColor">#ffffff</item>
<item name="android:shadowColor">#000000</item>
<item name="android:shadowRadius">2.0</item>
<item name="android:layout_marginBottom">0dp</item>
</style>
</resources>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:android="http://schemas.android.com/apk/res/android">
<style name="UnreadWidgetTextView">
<item name="android:textSize">12sp</item>
<item name="android:textColor">#ffffff</item>
<item name="android:shadowColor">#000000</item>
<item name="android:shadowDy">1</item>
<item name="android:shadowRadius">4.0</item>
<item name="android:paddingBottom">0dp</item>
</style>
</resources>

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:android="http://schemas.android.com/apk/res/android">
<style name="UnreadWidgetContainer">
<item name="android:background">@null</item>
<item name="android:paddingTop">0dp</item>
<item name="android:paddingLeft">0dp</item>
<item name="android:paddingRight">0dp</item>
<item name="android:paddingBottom">0dp</item>
<item name="android:gravity">bottom|center_horizontal</item>
</style>
<style name="UnreadWidgetTextView">
<item name="android:textSize">13sp</item>
<item name="android:textColor">#ffffff</item>
<item name="android:shadowColor">#000000</item>
<item name="android:shadowDy">1</item>
<item name="android:shadowRadius">4.0</item>
</style>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Home screen widgets should be disabled by default. They will be enabled programmatically if necessary. -->
<bool name="home_screen_widgets_enabled">false</bool>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="icon_background">#FFFFFF</color>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
We'd like to disable this component by default. However, due to a bug in Android versions prior to 12, users then
wouldn't be able to use the home screen widget.
See https://android.googlesource.com/platform/frameworks/base/+/85be035336af8d83eb24980026418207c85991cb%5E%21/#F0
-->
<bool name="home_screen_widgets_enabled">true</bool>
</resources>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="UnreadWidgetContainer">
<item name="android:paddingTop">0dp</item>
<item name="android:paddingLeft">0dp</item>
<item name="android:paddingRight">0dp</item>
<item name="android:paddingBottom">0dp</item>
<item name="android:background">@null</item>
<item name="android:gravity">center</item>
</style>
<style name="UnreadWidgetTextView">
<item name="android:textSize">12sp</item>
<item name="android:textColor">#ffffff</item>
<item name="android:shadowColor">#000000</item>
<item name="android:shadowDy">1</item>
<item name="android:shadowRadius">4.0</item>
</style>
</resources>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config xmlns:tools="http://schemas.android.com/tools"
tools:ignore="InsecureBaseConfiguration,AcceptsUserCertificates">
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<Preference
android:key="unread_account"
android:persistent="false"
android:singleLine="true"
android:summary="@string/unread_widget_account_summary"
android:title="@string/unread_widget_account_title" />
<CheckBoxPreference
android:key="unread_folder_enabled"
android:persistent="false"
android:enabled="false"
android:summary="@string/unread_widget_folder_enabled_summary"
android:title="@string/unread_widget_folder_enabled_title" />
<Preference
android:dependency="unread_folder_enabled"
android:key="unread_folder"
android:persistent="false"
android:singleLine="true"
android:summary="@string/unread_widget_folder_summary"
android:title="@string/unread_widget_folder_title" />
</PreferenceScreen>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/unread_widget_layout"
android:previewImage="@drawable/preview_unread_widget"
android:minHeight="40dp"
android:minWidth="40dp"
android:configure="com.fsck.k9.widget.unread.UnreadWidgetConfigurationActivity"
android:updatePeriodMillis="0">
</appwidget-provider>

View file

@ -0,0 +1,9 @@
package app.k9mail.dev
import com.fsck.k9.backend.BackendFactory
import org.koin.core.module.Module
import org.koin.core.scope.Scope
fun Scope.developmentBackends() = emptyMap<String, BackendFactory>()
fun Module.developmentModuleAdditions() = Unit

View file

@ -0,0 +1,14 @@
package com.fsck.k9
import android.app.Application
import org.junit.runner.RunWith
import org.koin.test.AutoCloseKoinTest
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* A Robolectric test that creates an instance of our [Application] class [App].
*/
@RunWith(RobolectricTestRunner::class)
@Config(application = App::class)
abstract class AppRobolectricTest : AutoCloseKoinTest()

View file

@ -0,0 +1,55 @@
package com.fsck.k9
import android.view.ContextThemeWrapper
import androidx.lifecycle.LifecycleOwner
import androidx.work.WorkerParameters
import com.fsck.k9.job.MailSyncWorker
import com.fsck.k9.ui.R
import com.fsck.k9.ui.changelog.ChangeLogMode
import com.fsck.k9.ui.changelog.ChangelogViewModel
import com.fsck.k9.ui.endtoend.AutocryptKeyTransferActivity
import com.fsck.k9.ui.endtoend.AutocryptKeyTransferPresenter
import com.fsck.k9.ui.folders.FolderIconProvider
import com.fsck.k9.ui.folders.FolderNameFormatter
import com.fsck.k9.ui.helper.SizeFormatter
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.annotation.KoinInternalApi
import org.koin.core.logger.PrintLogger
import org.koin.core.parameter.parametersOf
import org.koin.java.KoinJavaComponent
import org.koin.test.AutoCloseKoinTest
import org.koin.test.check.checkModules
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.openintents.openpgp.OpenPgpApiManager
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(application = App::class)
class DependencyInjectionTest : AutoCloseKoinTest() {
private val lifecycleOwner = mock<LifecycleOwner> {
on { lifecycle } doReturn mock()
}
private val autocryptTransferView = mock<AutocryptKeyTransferActivity>()
@KoinInternalApi
@Test
fun testDependencyTree() {
KoinJavaComponent.getKoin().setupLogger(PrintLogger())
getKoin().checkModules {
withParameter<OpenPgpApiManager> { lifecycleOwner }
withParameters<AutocryptKeyTransferPresenter> { parametersOf(lifecycleOwner, autocryptTransferView) }
withParameter<FolderNameFormatter> { RuntimeEnvironment.getApplication() }
withParameter<SizeFormatter> { RuntimeEnvironment.getApplication() }
withParameter<ChangelogViewModel> { ChangeLogMode.CHANGE_LOG }
withParameter<MailSyncWorker> { mock<WorkerParameters>() }
withParameter<FolderIconProvider> {
ContextThemeWrapper(RuntimeEnvironment.getApplication(), R.style.Theme_K9_DayNight).theme
}
}
}
}

View file

@ -0,0 +1,145 @@
package com.fsck.k9.widget.unread
import android.content.Context
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNull
import com.fsck.k9.Account
import com.fsck.k9.AppRobolectricTest
import com.fsck.k9.Preferences
import com.fsck.k9.controller.MessageCounts
import com.fsck.k9.controller.MessageCountsProvider
import com.fsck.k9.mailstore.Folder
import com.fsck.k9.mailstore.FolderRepository
import com.fsck.k9.mailstore.FolderType
import com.fsck.k9.search.SearchAccount
import com.fsck.k9.ui.folders.FolderNameFormatter
import com.fsck.k9.ui.messagelist.DefaultFolderProvider
import org.junit.Test
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.robolectric.RuntimeEnvironment
class UnreadWidgetDataProviderTest : AppRobolectricTest() {
private val context: Context = RuntimeEnvironment.getApplication()
private val account = createAccount()
private val preferences = createPreferences()
private val messageCountsProvider = createMessageCountsProvider()
private val defaultFolderStrategy = createDefaultFolderStrategy()
private val folderRepository = createFolderRepository()
private val folderNameFormatter = createFolderNameFormatter()
private val provider = UnreadWidgetDataProvider(
context,
preferences,
messageCountsProvider,
defaultFolderStrategy,
folderRepository,
folderNameFormatter
)
@Test
fun unifiedInbox() {
val configuration = UnreadWidgetConfiguration(
appWidgetId = 1,
accountUuid = SearchAccount.UNIFIED_INBOX,
folderId = null
)
val widgetData = provider.loadUnreadWidgetData(configuration)
with(widgetData!!) {
assertThat(title).isEqualTo("Unified Inbox")
assertThat(unreadCount).isEqualTo(SEARCH_ACCOUNT_UNREAD_COUNT)
}
}
@Test
fun regularAccount() {
val configuration = UnreadWidgetConfiguration(
appWidgetId = 3,
accountUuid = ACCOUNT_UUID,
folderId = null
)
val widgetData = provider.loadUnreadWidgetData(configuration)
with(widgetData!!) {
assertThat(title).isEqualTo(ACCOUNT_NAME)
assertThat(unreadCount).isEqualTo(ACCOUNT_UNREAD_COUNT)
}
}
@Test
fun folder() {
val configuration = UnreadWidgetConfiguration(appWidgetId = 4, accountUuid = ACCOUNT_UUID, folderId = FOLDER_ID)
val widgetData = provider.loadUnreadWidgetData(configuration)
with(widgetData!!) {
assertThat(title).isEqualTo("$ACCOUNT_NAME - $LOCALIZED_FOLDER_NAME")
assertThat(unreadCount).isEqualTo(FOLDER_UNREAD_COUNT)
}
}
@Test
fun nonExistentAccount_shouldReturnNull() {
val configuration = UnreadWidgetConfiguration(appWidgetId = 3, accountUuid = "invalid", folderId = null)
val widgetData = provider.loadUnreadWidgetData(configuration)
assertThat(widgetData).isNull()
}
private fun createAccount(): Account = mock {
on { uuid } doReturn ACCOUNT_UUID
on { displayName } doReturn ACCOUNT_NAME
}
private fun createPreferences(): Preferences = mock {
on { getAccount(ACCOUNT_UUID) } doReturn account
}
private fun createMessageCountsProvider() = object : MessageCountsProvider {
override fun getMessageCounts(account: Account): MessageCounts {
return MessageCounts(unread = ACCOUNT_UNREAD_COUNT, starred = 0)
}
override fun getMessageCounts(searchAccount: SearchAccount): MessageCounts {
return MessageCounts(unread = SEARCH_ACCOUNT_UNREAD_COUNT, starred = 0)
}
override fun getUnreadMessageCount(account: Account, folderId: Long): Int {
return FOLDER_UNREAD_COUNT
}
}
private fun createDefaultFolderStrategy(): DefaultFolderProvider = mock {
on { getDefaultFolder(account) } doReturn FOLDER_ID
}
private fun createFolderRepository(): FolderRepository {
return mock {
on { getFolder(account, FOLDER_ID) } doReturn FOLDER
}
}
private fun createFolderNameFormatter(): FolderNameFormatter = mock {
on { displayName(FOLDER) } doReturn LOCALIZED_FOLDER_NAME
}
companion object {
const val ACCOUNT_UUID = "00000000-0000-0000-0000-000000000000"
const val ACCOUNT_NAME = "Test account"
const val FOLDER_ID = 23L
const val SEARCH_ACCOUNT_UNREAD_COUNT = 1
const val ACCOUNT_UNREAD_COUNT = 2
const val FOLDER_UNREAD_COUNT = 3
const val LOCALIZED_FOLDER_NAME = "Posteingang"
val FOLDER = Folder(
id = FOLDER_ID,
name = "INBOX",
type = FolderType.INBOX,
isLocalOnly = false
)
}
}