Merge pull request #4459 from k9mail/jmap

Add JMAP backend module
This commit is contained in:
cketti 2020-01-19 16:18:37 +01:00 committed by GitHub
commit dd17a2bb5e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 3165 additions and 2 deletions

View file

@ -25,4 +25,5 @@ val mainModule = module {
single<TrustedSocketFactory> { DefaultTrustedSocketFactory(get(), get()) }
single { Clock.INSTANCE }
factory { ServerNameSuggester() }
factory { EmailAddressValidator() }
}

View file

@ -0,0 +1,140 @@
apply plugin: 'com.android.application'
apply plugin: 'org.jetbrains.kotlin.android'
apply plugin: 'org.jetbrains.kotlin.android.extensions'
apply plugin: 'kotlin-kapt'
apply plugin: 'org.jlleitschuh.gradle.ktlint'
if (rootProject.testCoverage) {
apply plugin: 'jacoco'
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${versions.kotlin}"
implementation project(":app:ui")
implementation project(":app:core")
implementation project(":app:storage")
implementation project(":app:crypto-openpgp")
implementation project(":backend:imap")
implementation project(":backend:pop3")
implementation project(":backend:webdav")
implementation project(":backend:jmap")
implementation "androidx.appcompat:appcompat:${versions.androidxAppCompat}"
implementation "com.jakewharton.timber:timber:${versions.timber}"
implementation "androidx.constraintlayout:constraintlayout:${versions.androidxConstraintLayout}"
implementation "com.google.android.material:material:${versions.materialComponents}"
implementation "androidx.navigation:navigation-fragment-ktx:${versions.androidxNavigation}"
implementation "androidx.navigation:navigation-ui-ktx:${versions.androidxNavigation}"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0"
testImplementation "org.robolectric:robolectric:${versions.robolectric}"
testImplementation "junit:junit:${versions.junit}"
testImplementation "com.google.truth:truth:${versions.truth}"
testImplementation "org.mockito:mockito-core:${versions.mockito}"
testImplementation "com.nhaarman:mockito-kotlin:${versions.mockitoKotlin}"
testImplementation "org.jdom:jdom2:2.0.6"
testImplementation "org.koin:koin-test:${versions.koin}"
androidTestImplementation "androidx.test.espresso:espresso-core:3.1.1"
}
android {
compileSdkVersion buildConfig.compileSdk
buildToolsVersion buildConfig.buildTools
defaultConfig {
applicationId "com.fsck.k9.jmap"
testApplicationId "com.fsck.k9.jmap.tests"
versionCode 1
versionName 'JMAP DEV'
minSdkVersion buildConfig.minSdk
targetSdkVersion buildConfig.targetSdk
generatedDensities = ['mdpi', 'hdpi', 'xhdpi']
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
release
}
buildTypes {
release {
if (project.hasProperty('storeFile')) {
signingConfig signingConfigs.release
}
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
buildConfigField "boolean", "DEVELOPER_MODE", "false"
}
debug {
applicationIdSuffix ".debug"
testCoverageEnabled rootProject.testCoverage
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
buildConfigField "boolean", "DEVELOPER_MODE", "true"
}
}
// Do not abort build if lint finds errors
lintOptions {
abortOnError false
lintConfig file("$rootProject.projectDir/config/lint/lint.xml")
}
packagingOptions {
exclude 'META-INF/DEPENDENCIES'
exclude 'META-INF/LICENSE'
exclude 'META-INF/LICENSE.txt'
exclude 'META-INF/NOTICE'
exclude 'META-INF/NOTICE.txt'
exclude 'META-INF/README'
exclude 'LICENSE.txt'
exclude 'META-INF/*.kotlin_module'
}
compileOptions {
sourceCompatibility javaVersion
targetCompatibility javaVersion
}
kotlinOptions {
jvmTarget = kotlinJvmVersion
}
dataBinding {
enabled = true
}
testOptions {
unitTests {
includeAndroidResources = true
}
}
}
androidExtensions {
experimental = true
}
if (project.hasProperty('keyAlias')) {
android.signingConfigs.release.keyAlias = keyAlias
}
if (project.hasProperty('keyPassword')) {
android.signingConfigs.release.keyPassword = keyPassword
}
if (project.hasProperty('storeFile')) {
android.signingConfigs.release.storeFile = file(storeFile)
}
if (project.hasProperty('storePassword')) {
android.signingConfigs.release.storePassword = storePassword
}

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

@ -0,0 +1,44 @@
# 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.**
-keep class rs.ltt.jmap.common.** {*;}
-dontwarn okio.**
-dontwarn com.squareup.moshi.**
# Project specific rules
-dontnote com.fsck.k9.ui.messageview.**
-dontnote com.fsck.k9.view.**
-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,362 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
android:installLocation="auto"
package="com.fsck.k9.jmap">
<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"/>
<!-- Needed to mark a contact as contacted -->
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
<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"/>
<application
android:name="com.fsck.k9.App"
android:allowTaskReparenting="false"
android:usesCleartextTraffic="true"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.K9.Startup"
android:resizeableActivity="true"
android:allowBackup="false">
<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"/>
<activity
android:name="com.fsck.k9.ui.onboarding.OnboardingActivity"
android:label="@string/welcome_message_title" />
<activity
android:name="com.fsck.k9.ui.settings.account.OpenPgpAppSelectDialog"
android:configChanges="locale"
android:theme="@style/Theme.K9.Transparent"
/>
<activity
android:name="com.fsck.k9.activity.setup.FontSizeSettings"
android:configChanges="locale"
android:label="@string/font_size_settings_title"/>
<activity
android:name="com.fsck.k9.activity.setup.AccountSetupBasics"
android:configChanges="locale"
android:label="@string/account_setup_basics_title"/>
<activity
android:name="com.fsck.k9.activity.setup.AccountSetupAccountType"
android:configChanges="locale"
android:label="@string/account_setup_account_type_title"/>
<activity
android:name="com.fsck.k9.activity.setup.AccountSetupIncoming"
android:configChanges="locale"
android:label="@string/account_setup_incoming_title"/>
<activity
android:name="com.fsck.k9.activity.setup.AccountSetupComposition"
android:configChanges="locale"
android:label="@string/account_settings_composition_title"/>
<activity
android:name="com.fsck.k9.activity.setup.AccountSetupOutgoing"
android:configChanges="locale"
android:label="@string/account_setup_outgoing_title"/>
<activity
android:name="com.fsck.k9.activity.setup.AccountSetupOptions"
android:configChanges="locale"
android:label="@string/account_setup_options_title"/>
<activity
android:name="com.fsck.k9.activity.setup.AccountSetupNames"
android:configChanges="locale"
android:label="@string/account_setup_names_title"/>
<activity
android:name="com.fsck.k9.activity.ChooseAccount"
android:configChanges="locale"
android:label="@string/choose_account_title"
android:noHistory="true" />
<activity
android:name="com.fsck.k9.ui.choosefolder.ChooseFolderActivity"
android:configChanges="locale"
android:label="@string/choose_folder_title"
android:noHistory="true" />
<activity
android:name="com.fsck.k9.activity.ChooseIdentity"
android:configChanges="locale"
android:label="@string/choose_identity_title" />
<activity
android:name="com.fsck.k9.activity.ManageIdentities"
android:configChanges="locale"
android:label="@string/manage_identities_title"/>
<activity
android:name="com.fsck.k9.activity.EditIdentity"
android:configChanges="locale"
android:label="@string/edit_identity_title"/>
<activity
android:name="com.fsck.k9.activity.NotificationDeleteConfirmation"
android:excludeFromRecents="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@android:style/Theme.Translucent.NoTitleBar"/>
<!-- XXX Note: this activity is hacked to ignore config changes,
since it doesn't currently handle them correctly in code. -->
<activity
android:name="com.fsck.k9.activity.setup.AccountSetupCheckSettings"
android:configChanges="keyboardHidden|orientation|locale"
android:label="@string/account_setup_check_settings_title"/>
<activity
android:name="com.fsck.k9.activity.setup.FolderSettings"
android:configChanges="locale"
android:label="@string/folder_settings_title"
android:theme="@android:style/Theme.Dialog"/>
<activity
android:name="com.fsck.k9.ui.endtoend.AutocryptKeyTransferActivity"
android:configChanges="locale"
android:label="@string/ac_transfer_title"
/>
<activity android:name="com.fsck.k9.activity.MessageList">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.LAUNCHER"/>
<!-- 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>
</activity>
<activity
android:name="com.fsck.k9.activity.MessageCompose"
android:configChanges="locale"
android:enabled="false"
android:label="@string/app_name">
<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>
<meta-data
android:name="android.service.chooser.chooser_target_service"
android:value="com.fsck.k9.directshare.K9ChooserTargetService" />
</activity>
<!-- Search Activity - searchable -->
<activity
android:name="com.fsck.k9.activity.Search"
android:configChanges="locale"
android:label="@string/search_action"
android:uiOptions="splitActionBarWhenNarrow">
<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>
<activity
android:name="com.fsck.k9.activity.UpgradeDatabases"
android:label="@string/upgrade_databases_title"/>
<activity
android:name="com.fsck.k9.ui.managefolders.ManageFoldersActivity"
android:label="@string/folders_action" />
<activity
android:name="com.fsck.k9.ui.settings.SettingsActivity"
android:label="@string/prefs_title" />
<activity
android:name="com.fsck.k9.ui.settings.general.GeneralSettingsActivity"
android:label="@string/general_settings_title" />
<activity
android:name="com.fsck.k9.ui.settings.account.AccountSettingsActivity"
android:label="@string/account_settings_title_fmt" />
<activity
android:name="com.fsck.k9.ui.addaccount.AddAccountActivity"
android:label="@string/add_account_action" />
<receiver
android:name="com.fsck.k9.service.BootReceiver"
android:enabled="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>
<intent-filter>
<action android:name="android.net.conn.BACKGROUND_DATA_SETTING_CHANGED"/>
</intent-filter>
<intent-filter>
<action android:name="com.android.sync.SYNC_CONN_STATUS_CHANGED"/>
</intent-filter>
</receiver>
<receiver
android:name="com.fsck.k9.service.CoreReceiver"
android:exported="false"/>
<receiver
android:name="com.fsck.k9.service.StorageReceiver"
android:enabled="true">
<intent-filter>
<!--
android.intent.action.MEDIA_MOUNTED
* Broadcast Action: External media is present and mounted at its mount point.
* The path to the mount point for the removed media is contained in the Intent.mData field.
* The Intent contains an extra with name "read-only" and Boolean value to indicate if the
* media was mounted read only.
-->
<action android:name="android.intent.action.MEDIA_MOUNTED"/>
<!--
MEDIA_EJECT and MEDIA_UNMOUNTED are not defined here: they have to be dynamically registered
otherwise it would make K-9 start at the wrong time
-->
<data android:scheme="file"/>
</intent-filter>
</receiver>
<service
android:name="com.fsck.k9.notification.NotificationActionService"
android:enabled="true"/>
<service
android:name="com.fsck.k9.service.PushService"
android:enabled="true"/>
<service
android:name="com.fsck.k9.service.SleepService"
android:enabled="true"/>
<service
android:name="com.fsck.k9.service.DatabaseUpgradeService"
android:exported="false"/>
<service
android:name="com.fsck.k9.account.AccountRemoverService"
android:permission="android.permission.BIND_JOB_SERVICE"/>
<provider
android:name="com.fsck.k9.provider.AttachmentProvider"
android:authorities="${applicationId}.attachmentprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="de.cketti.safecontentresolver.ALLOW_INTERNAL_ACCESS"
android:value="true" />
</provider>
<provider
android:name="com.fsck.k9.provider.RawMessageProvider"
android:authorities="${applicationId}.rawmessageprovider"
android:exported="false">
<meta-data
android:name="de.cketti.safecontentresolver.ALLOW_INTERNAL_ACCESS"
android:value="true" />
</provider>
<provider
android:name="com.fsck.k9.provider.EmailProvider"
android:authorities="${applicationId}.provider.email"
android:exported="false"/>
<provider
android:name="com.fsck.k9.provider.DecryptedFileProvider"
android:authorities="${applicationId}.decryptedfileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/decrypted_file_provider_paths" />
</provider>
<provider
android:name="com.fsck.k9.provider.AttachmentTempFileProvider"
android:authorities="${applicationId}.tempfileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/temp_file_provider_paths" />
</provider>
</application>
</manifest>

View file

@ -0,0 +1,35 @@
package com.fsck.k9
import android.app.Application
import com.fsck.k9.activity.MessageCompose
import com.fsck.k9.controller.MessagingController
import com.fsck.k9.ui.ThemeManager
import org.koin.android.ext.android.inject
class App : Application() {
private val messagingController: MessagingController by inject()
private val messagingListenerProvider: MessagingListenerProvider by inject()
private val themeManager: ThemeManager by inject()
override fun onCreate() {
Core.earlyInit(this)
super.onCreate()
DI.start(this, coreModules + uiModules + appModules)
K9.init(this)
Core.init(this)
themeManager.init()
messagingListenerProvider.listeners.forEach { listener ->
messagingController.addListener(listener)
}
}
companion object {
val appConfig = AppConfig(
componentsToDisable = listOf(MessageCompose::class.java)
)
}
}

View file

@ -0,0 +1,31 @@
package com.fsck.k9
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.ui.addaccount.uiAddAccountModule
import org.koin.core.qualifier.named
import org.koin.dsl.module
private val mainAppModule = module {
single { App.appConfig }
single { MessagingListenerProvider(emptyList()) }
single(named("controllerExtensions")) { emptyList<ControllerExtension>() }
single<EncryptionExtractor> { OpenPgpEncryptionExtractor.newInstance() }
single<StoragePersister> { K9StoragePersister(get()) }
}
val appModules = listOf(
mainAppModule,
notificationModule,
resourcesModule,
backendsModule,
storageModule,
uiAddAccountModule
)

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,70 @@
package com.fsck.k9.backends
import android.content.Context
import android.net.ConnectivityManager
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.ImapStoreUriCreator
import com.fsck.k9.backend.imap.ImapStoreUriDecoder
import com.fsck.k9.mail.ServerSettings
import com.fsck.k9.mail.oauth.OAuth2TokenProvider
import com.fsck.k9.mail.power.PowerManager
import com.fsck.k9.mail.ssl.TrustedSocketFactory
import com.fsck.k9.mail.store.imap.ImapStore
import com.fsck.k9.mail.transport.smtp.SmtpTransport
import com.fsck.k9.mail.transport.smtp.SmtpTransportUriCreator
import com.fsck.k9.mail.transport.smtp.SmtpTransportUriDecoder
import com.fsck.k9.mailstore.K9BackendStorageFactory
class ImapBackendFactory(
private val context: Context,
private val powerManager: PowerManager,
private val backendStorageFactory: K9BackendStorageFactory,
private val trustedSocketFactory: TrustedSocketFactory
) : BackendFactory {
override val transportUriPrefix = "smtp"
override fun createBackend(account: Account): Backend {
val accountName = account.displayName
val backendStorage = backendStorageFactory.createBackendStorage(account)
val imapStore = createImapStore(account)
val smtpTransport = createSmtpTransport(account)
return ImapBackend(accountName, backendStorage, imapStore, powerManager, smtpTransport)
}
private fun createImapStore(account: Account): ImapStore {
val oAuth2TokenProvider: OAuth2TokenProvider? = null
val serverSettings = ImapStoreUriDecoder.decode(account.storeUri)
return ImapStore(
serverSettings,
account,
trustedSocketFactory,
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager,
oAuth2TokenProvider
)
}
private fun createSmtpTransport(account: Account): SmtpTransport {
val serverSettings = decodeTransportUri(account.transportUri)
val oauth2TokenProvider: OAuth2TokenProvider? = null
return SmtpTransport(serverSettings, trustedSocketFactory, oauth2TokenProvider)
}
override fun decodeStoreUri(storeUri: String): ServerSettings {
return ImapStoreUriDecoder.decode(storeUri)
}
override fun createStoreUri(serverSettings: ServerSettings): String {
return ImapStoreUriCreator.create(serverSettings)
}
override fun decodeTransportUri(transportUri: String): ServerSettings {
return SmtpTransportUriDecoder.decodeSmtpUri(transportUri)
}
override fun createTransportUri(serverSettings: ServerSettings): String {
return SmtpTransportUriCreator.createSmtpUri(serverSettings)
}
}

View file

@ -0,0 +1,60 @@
package com.fsck.k9.backends
import com.fsck.k9.Account
import com.fsck.k9.Preferences
import com.fsck.k9.account.AccountCreator
import com.fsck.k9.backend.BackendManager
import com.fsck.k9.backend.jmap.JmapDiscoveryResult.JmapAccount
import com.fsck.k9.mail.AuthType
import com.fsck.k9.mail.ConnectionSecurity
import com.fsck.k9.mail.ServerSettings
import com.fsck.k9.mailstore.LocalStoreProvider
class JmapAccountCreator(
private val preferences: Preferences,
private val backendManager: BackendManager,
private val accountCreator: AccountCreator,
private val localStoreProvider: LocalStoreProvider
) {
fun createAccount(emailAddress: String, password: String, jmapAccount: JmapAccount) {
val serverSettings = createServerSettings(emailAddress, password, jmapAccount)
val account = preferences.newAccount().apply {
email = emailAddress
description = jmapAccount.name
storeUri = backendManager.createStoreUri(serverSettings)
transportUri = backendManager.createTransportUri(serverSettings)
chipColor = accountCreator.pickColor()
deletePolicy = Account.DeletePolicy.ON_DELETE
}
preferences.saveAccount(account)
createOutboxFolder(account)
fetchFolderList(account)
}
private fun createServerSettings(emailAddress: String, password: String, jmapAccount: JmapAccount): ServerSettings {
return ServerSettings(
"jmap",
null,
433,
ConnectionSecurity.SSL_TLS_REQUIRED,
AuthType.PLAIN,
emailAddress,
password,
null,
mapOf("accountId" to jmapAccount.accountId)
)
}
private fun createOutboxFolder(account: Account) {
val localStore = localStoreProvider.getInstance(account)
localStore.createLocalFolder(Account.OUTBOX, Account.OUTBOX_NAME)
}
private fun fetchFolderList(account: Account) {
val backend = backendManager.getBackend(account)
backend.refreshFolderList()
}
}

View file

@ -0,0 +1,73 @@
package com.fsck.k9.backends
import android.net.Uri
import com.fsck.k9.Account
import com.fsck.k9.backend.BackendFactory
import com.fsck.k9.backend.api.Backend
import com.fsck.k9.backend.jmap.JmapBackend
import com.fsck.k9.backend.jmap.JmapConfig
import com.fsck.k9.mail.AuthType
import com.fsck.k9.mail.ConnectionSecurity
import com.fsck.k9.mail.ServerSettings
import com.fsck.k9.mailstore.K9BackendStorageFactory
class JmapBackendFactory(private val backendStorageFactory: K9BackendStorageFactory) : BackendFactory {
override val transportUriPrefix = "jmap"
override fun createBackend(account: Account): Backend {
val backendStorage = backendStorageFactory.createBackendStorage(account)
val serverSettings = decodeStoreUri(account.storeUri)
val jmapConfig = JmapConfig(
username = serverSettings.username,
password = serverSettings.password,
baseUrl = serverSettings.host,
accountId = serverSettings.extra["accountId"]!!
)
return JmapBackend(backendStorage, jmapConfig)
}
override fun decodeStoreUri(storeUri: String): ServerSettings {
val uri = Uri.parse(storeUri)
val username = uri.getQueryParameter("username")
val password = uri.getQueryParameter("password")
val baseUrl = uri.getQueryParameter("baseUrl")
val accountId = uri.getQueryParameter("accountId")
val extra = mapOf(
"accountId" to accountId
)
return ServerSettings("jmap", baseUrl, 433, ConnectionSecurity.SSL_TLS_REQUIRED, AuthType.PLAIN, username, password, null, extra)
}
override fun createStoreUri(serverSettings: ServerSettings): String {
val username = serverSettings.username
val password = serverSettings.password
val baseUrl = serverSettings.host
val accountId = serverSettings.extra["accountId"]
return Uri.Builder()
.scheme("jmap")
.authority("unused")
.appendQueryParameter("username", username)
.appendQueryParameter("password", password)
.apply {
if (baseUrl != null) {
appendQueryParameter("baseUrl", baseUrl)
}
}
.appendQueryParameter("accountId", accountId)
.build()
.toString()
}
override fun decodeTransportUri(transportUri: String): ServerSettings {
return decodeStoreUri(transportUri)
}
override fun createTransportUri(serverSettings: ServerSettings): String {
return createStoreUri(serverSettings)
}
}

View file

@ -0,0 +1,23 @@
package com.fsck.k9.backends
import com.fsck.k9.backend.BackendManager
import com.fsck.k9.backend.jmap.JmapAccountDiscovery
import org.koin.dsl.module
val backendsModule = module {
single {
BackendManager(
mapOf(
"imap" to get<ImapBackendFactory>(),
"pop3" to get<Pop3BackendFactory>(),
"webdav" to get<WebDavBackendFactory>(),
"jmap" to get<JmapBackendFactory>()
))
}
single { ImapBackendFactory(get(), get(), get(), get()) }
single { Pop3BackendFactory(get(), get()) }
single { WebDavBackendFactory(get(), get()) }
single { JmapBackendFactory(get()) }
factory { JmapAccountDiscovery() }
factory { JmapAccountCreator(get(), get(), get(), get()) }
}

View file

@ -0,0 +1,58 @@
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.backend.pop3.Pop3StoreUriCreator
import com.fsck.k9.backend.pop3.Pop3StoreUriDecoder
import com.fsck.k9.mail.ServerSettings
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.mail.transport.smtp.SmtpTransportUriCreator
import com.fsck.k9.mail.transport.smtp.SmtpTransportUriDecoder
import com.fsck.k9.mailstore.K9BackendStorageFactory
class Pop3BackendFactory(
private val backendStorageFactory: K9BackendStorageFactory,
private val trustedSocketFactory: TrustedSocketFactory
) : BackendFactory {
override val transportUriPrefix = "smtp"
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 = decodeStoreUri(account.storeUri)
return Pop3Store(serverSettings, account, trustedSocketFactory)
}
private fun createSmtpTransport(account: Account): SmtpTransport {
val serverSettings = decodeTransportUri(account.transportUri)
val oauth2TokenProvider: OAuth2TokenProvider? = null
return SmtpTransport(serverSettings, trustedSocketFactory, oauth2TokenProvider)
}
override fun decodeStoreUri(storeUri: String): ServerSettings {
return Pop3StoreUriDecoder.decode(storeUri)
}
override fun createStoreUri(serverSettings: ServerSettings): String {
return Pop3StoreUriCreator.create(serverSettings)
}
override fun decodeTransportUri(transportUri: String): ServerSettings {
return SmtpTransportUriDecoder.decodeSmtpUri(transportUri)
}
override fun createTransportUri(serverSettings: ServerSettings): String {
return SmtpTransportUriCreator.createSmtpUri(serverSettings)
}
}

View file

@ -0,0 +1,50 @@
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.backend.webdav.WebDavStoreUriCreator
import com.fsck.k9.backend.webdav.WebDavStoreUriDecoder
import com.fsck.k9.mail.ServerSettings
import com.fsck.k9.mail.ssl.TrustManagerFactory
import com.fsck.k9.mail.store.webdav.WebDavStore
import com.fsck.k9.mail.store.webdav.WebDavStoreSettings
import com.fsck.k9.mail.transport.WebDavTransport
import com.fsck.k9.mailstore.K9BackendStorageFactory
class WebDavBackendFactory(
private val backendStorageFactory: K9BackendStorageFactory,
private val trustManagerFactory: TrustManagerFactory
) : BackendFactory {
override val transportUriPrefix = "webdav"
override fun createBackend(account: Account): Backend {
val accountName = account.displayName
val backendStorage = backendStorageFactory.createBackendStorage(account)
val serverSettings = WebDavStoreUriDecoder.decode(account.storeUri)
val webDavStore = createWebDavStore(serverSettings, account)
val webDavTransport = WebDavTransport(trustManagerFactory, serverSettings, account)
return WebDavBackend(accountName, backendStorage, webDavStore, webDavTransport)
}
private fun createWebDavStore(serverSettings: WebDavStoreSettings, account: Account): WebDavStore {
return WebDavStore(trustManagerFactory, serverSettings, account)
}
override fun decodeStoreUri(storeUri: String): ServerSettings {
return WebDavStoreUriDecoder.decode(storeUri)
}
override fun createStoreUri(serverSettings: ServerSettings): String {
return WebDavStoreUriCreator.create(serverSettings)
}
override fun decodeTransportUri(transportUri: String): ServerSettings {
return WebDavStoreUriDecoder.decode(transportUri)
}
override fun createTransportUri(serverSettings: ServerSettings): String {
return WebDavStoreUriCreator.create(serverSettings)
}
}

View file

@ -0,0 +1,245 @@
package com.fsck.k9.notification;
import java.util.List;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.text.TextUtils;
import com.fsck.k9.Account;
import com.fsck.k9.DI;
import com.fsck.k9.K9;
import com.fsck.k9.activity.MessageList;
import com.fsck.k9.activity.NotificationDeleteConfirmation;
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.jmap.R;
import com.fsck.k9.search.AccountSearchConditions;
import com.fsck.k9.search.LocalSearch;
import com.fsck.k9.ui.messagelist.DefaultFolderProvider;
/**
* This class contains methods to create the {@link PendingIntent}s for the actions of our notifications.
* <p/>
* <strong>Note:</strong>
* We need to take special care to ensure the {@code PendingIntent}s are unique as defined in the documentation of
* {@link PendingIntent}. Otherwise selecting a notification action might perform the action on the wrong message.
* <p/>
* We use the notification ID as {@code requestCode} argument to ensure each notification/action pair gets a unique
* {@code PendingIntent}.
*/
class K9NotificationActionCreator implements NotificationActionCreator {
private final Context context;
private final AccountSearchConditions accountSearchConditions = DI.get(AccountSearchConditions.class);
private final DefaultFolderProvider defaultFolderProvider = DI.get(DefaultFolderProvider.class);
public K9NotificationActionCreator(Context context) {
this.context = context;
}
@Override
public PendingIntent createViewMessagePendingIntent(MessageReference messageReference, int notificationId) {
Intent intent = createMessageViewIntent(messageReference);
return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
}
@Override
public PendingIntent createViewFolderPendingIntent(Account account, String folderServerId, int notificationId) {
Intent intent = createMessageListIntent(account, folderServerId);
return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
}
@Override
public PendingIntent createViewMessagesPendingIntent(Account account, List<MessageReference> messageReferences,
int notificationId) {
Intent intent;
if (account.isGoToUnreadMessageSearch()) {
intent = createUnreadIntent(account);
} else {
String folderServerId = getFolderServerIdOfAllMessages(messageReferences);
if (folderServerId == null) {
intent = createMessageListIntent(account);
} else {
intent = createMessageListIntent(account, folderServerId);
}
}
return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
}
@Override
public PendingIntent createViewFolderListPendingIntent(Account account, int notificationId) {
Intent intent = createMessageListIntent(account);
return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
}
@Override
public PendingIntent createDismissAllMessagesPendingIntent(Account account, int notificationId) {
Intent intent = NotificationActionService.createDismissAllMessagesIntent(context, account);
return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
}
@Override
public PendingIntent createDismissMessagePendingIntent(Context context, MessageReference messageReference,
int notificationId) {
Intent intent = NotificationActionService.createDismissMessageIntent(context, messageReference);
return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
}
@Override
public PendingIntent createReplyPendingIntent(MessageReference messageReference, int notificationId) {
Intent intent = MessageActions.getActionReplyIntent(context, messageReference);
return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
}
@Override
public PendingIntent createMarkMessageAsReadPendingIntent(MessageReference messageReference, int notificationId) {
Intent intent = NotificationActionService.createMarkMessageAsReadIntent(context, messageReference);
return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
}
@Override
public PendingIntent createMarkAllAsReadPendingIntent(Account account, List<MessageReference> messageReferences,
int notificationId) {
String accountUuid = account.getUuid();
Intent intent = NotificationActionService.createMarkAllAsReadIntent(context, accountUuid, messageReferences);
return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
}
@Override
public PendingIntent getEditIncomingServerSettingsIntent(Account account) {
Intent intent = AccountSetupIncoming.intentActionEditIncomingSettings(context, account);
return PendingIntent.getActivity(context, account.getAccountNumber(), intent,
PendingIntent.FLAG_UPDATE_CURRENT);
}
@Override
public PendingIntent getEditOutgoingServerSettingsIntent(Account account) {
Intent intent = AccountSetupOutgoing.intentActionEditOutgoingSettings(context, account);
return PendingIntent.getActivity(context, account.getAccountNumber(), intent,
PendingIntent.FLAG_UPDATE_CURRENT);
}
@Override
public PendingIntent createDeleteMessagePendingIntent(MessageReference messageReference, int notificationId) {
if (K9.isConfirmDeleteFromNotification()) {
return createDeleteConfirmationPendingIntent(messageReference, notificationId);
} else {
return createDeleteServicePendingIntent(messageReference, notificationId);
}
}
private PendingIntent createDeleteServicePendingIntent(MessageReference messageReference, int notificationId) {
Intent intent = NotificationActionService.createDeleteMessageIntent(context, messageReference);
return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
}
private PendingIntent createDeleteConfirmationPendingIntent(MessageReference messageReference, int notificationId) {
Intent intent = NotificationDeleteConfirmation.getIntent(context, messageReference);
return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
}
@Override
public PendingIntent createDeleteAllPendingIntent(Account account, List<MessageReference> messageReferences,
int notificationId) {
if (K9.isConfirmDeleteFromNotification()) {
return getDeleteAllConfirmationPendingIntent(messageReferences, notificationId);
} else {
return getDeleteAllServicePendingIntent(account, messageReferences, notificationId);
}
}
private PendingIntent getDeleteAllConfirmationPendingIntent(List<MessageReference> messageReferences,
int notificationId) {
Intent intent = NotificationDeleteConfirmation.getIntent(context, messageReferences);
return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_CANCEL_CURRENT);
}
private PendingIntent getDeleteAllServicePendingIntent(Account account, List<MessageReference> messageReferences,
int notificationId) {
String accountUuid = account.getUuid();
Intent intent = NotificationActionService.createDeleteAllMessagesIntent(
context, accountUuid, messageReferences);
return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
}
@Override
public PendingIntent createArchiveMessagePendingIntent(MessageReference messageReference, int notificationId) {
Intent intent = NotificationActionService.createArchiveMessageIntent(context, messageReference);
return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
}
@Override
public PendingIntent createArchiveAllPendingIntent(Account account, List<MessageReference> messageReferences,
int notificationId) {
Intent intent = NotificationActionService.createArchiveAllIntent(context, account, messageReferences);
return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
}
@Override
public PendingIntent createMarkMessageAsSpamPendingIntent(MessageReference messageReference, int notificationId) {
Intent intent = NotificationActionService.createMarkMessageAsSpamIntent(context, messageReference);
return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
}
private Intent createUnreadIntent(final Account account) {
String searchTitle = context.getString(R.string.search_title, account.getDescription(), context.getString(R.string.unread_modifier));
LocalSearch search = accountSearchConditions.createUnreadSearch(account, searchTitle);
return MessageList.intentDisplaySearch(context, search, true, false, false);
}
private Intent createMessageListIntent(Account account) {
String folderServerId = defaultFolderProvider.getDefaultFolder(account);
LocalSearch search = new LocalSearch(folderServerId);
search.addAllowedFolder(folderServerId);
search.addAccountUuid(account.getUuid());
return MessageList.intentDisplaySearch(context, search, false, true, true);
}
private Intent createMessageListIntent(Account account, String folderServerId) {
LocalSearch search = new LocalSearch(folderServerId);
search.addAllowedFolder(folderServerId);
search.addAccountUuid(account.getUuid());
return MessageList.intentDisplaySearch(context, search, false, true, true);
}
private Intent createMessageViewIntent(MessageReference message) {
return MessageList.actionDisplayMessageIntent(context, message);
}
private String getFolderServerIdOfAllMessages(List<MessageReference> messageReferences) {
MessageReference firstMessage = messageReferences.get(0);
String folderServerId = firstMessage.getFolderServerId();
for (MessageReference messageReference : messageReferences) {
if (!TextUtils.equals(folderServerId, messageReference.getFolderServerId())) {
return null;
}
}
return folderServerId;
}
}

View file

@ -0,0 +1,92 @@
package com.fsck.k9.notification
import android.content.Context
import com.fsck.k9.jmap.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.ic_notify_check_mail
override val iconCheckingMail: Int = R.drawable.ic_notify_check_mail
override val wearIconMarkAsRead: Int = R.drawable.ic_opened_envelope_dark
override val wearIconDelete: Int = R.drawable.ic_trash_can_dark
override val wearIconArchive: Int = R.drawable.ic_archive_dark
override val wearIconReplyAll: Int = R.drawable.ic_reply_all_dark
override val wearIconMarkAsSpam: Int = R.drawable.ic_alert_octagon_dark
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 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 newMailTitle(): String = context.getString(R.string.notification_new_title)
override fun newMailUnreadMessageCount(unreadMessageCount: Int, accountName: String): String =
context.getString(R.string.notification_new_one_account_fmt, unreadMessageCount, accountName)
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,69 @@
package com.fsck.k9.notification
import com.fsck.k9.Account
import com.fsck.k9.K9
import com.fsck.k9.helper.Contacts
import com.fsck.k9.mail.Flag
import com.fsck.k9.mailstore.LocalFolder
import com.fsck.k9.mailstore.LocalMessage
class K9NotificationStrategy(val contacts: Contacts) : NotificationStrategy {
override fun shouldNotifyForMessage(
account: Account,
localFolder: LocalFolder,
message: LocalMessage,
isOldMessage: Boolean
): Boolean {
// If we don't even have an account name, don't show the notification.
// (This happens during initial account setup)
if (account.name == null) {
return false
}
if (K9.isQuietTime && !K9.isNotificationDuringQuietTimeEnabled) {
return false
}
// Do not notify if the user does not have notifications enabled or if the message has
// been read.
if (!account.isNotifyNewMail || message.isSet(Flag.SEEN) || isOldMessage) {
return false
}
val aDisplayMode = account.folderDisplayMode
val aNotifyMode = account.folderNotifyNewMailMode
val fDisplayClass = localFolder.displayClass
val fNotifyClass = localFolder.notifyClass
if (LocalFolder.isModeMismatch(aDisplayMode, fDisplayClass)) {
// Never notify a folder that isn't displayed
return false
}
if (LocalFolder.isModeMismatch(aNotifyMode, fNotifyClass)) {
// Do not notify folders in the wrong class
return false
}
// No notification for new messages in Trash, Drafts, Spam or Sent folder.
// But do notify if it's the INBOX (see issue 1817).
val folder = message.folder
if (folder != null) {
val folderServerId = folder.serverId
if (folderServerId != account.inboxFolder && (folderServerId == account.trashFolder ||
folderServerId == account.draftsFolder ||
folderServerId == account.spamFolder ||
folderServerId == account.sentFolder)) {
return false
}
}
// Don't notify if the sender address matches one of our identities and the user chose not
// to be notified for such messages.
return if (account.isAnIdentity(message.from) && !account.isNotifySelfNewMail) {
false
} else !account.isNotifyContactsMailOnly || contacts.isAnyInContacts(message.from)
}
}

View file

@ -0,0 +1,9 @@
package com.fsck.k9.notification
import org.koin.dsl.module
val notificationModule = module {
single<NotificationActionCreator> { K9NotificationActionCreator(get()) }
single<NotificationResourceProvider> { K9NotificationResourceProvider(get()) }
single<NotificationStrategy> { K9NotificationStrategy(get()) }
}

View file

@ -0,0 +1,10 @@
package com.fsck.k9.resources
import android.content.Context
import com.fsck.k9.autocrypt.AutocryptStringProvider
import com.fsck.k9.jmap.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,45 @@
package com.fsck.k9.resources
import android.content.Context
import com.fsck.k9.CoreResourceProvider
import com.fsck.k9.jmap.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 sendAlternateChooserTitle(): String = context.getString(R.string.send_alternate_chooser_title)
override fun internalStorageProviderName(): String =
context.getString(R.string.local_storage_provider_internal_label)
override fun externalStorageProviderName(): String =
context.getString(R.string.local_storage_provider_external_label)
override fun contactDisplayNamePrefix(): String = context.getString(R.string.message_to_label)
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 searchAllMessagesTitle(): String = context.getString(R.string.search_all_messages_title)
override fun searchAllMessagesDetail(): String = context.getString(R.string.search_all_messages_detail)
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)
}

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,20 @@
package com.fsck.k9.ui
import android.view.View
import androidx.databinding.BindingAdapter
import com.google.android.material.textfield.TextInputLayout
@BindingAdapter("isVisible")
fun setVisibility(view: View, value: Boolean) {
view.visibility = if (value) View.VISIBLE else View.GONE
}
@BindingAdapter("error")
fun setError(view: TextInputLayout, value: Int?) {
if (value == null) {
view.error = null
} else {
val errorString = view.context.getString(value)
view.error = errorString
}
}

View file

@ -0,0 +1,31 @@
package com.fsck.k9.ui.addaccount
import android.os.Bundle
import androidx.navigation.NavController
import androidx.navigation.findNavController
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupActionBarWithNavController
import com.fsck.k9.activity.K9Activity
import com.fsck.k9.jmap.R
class AddAccountActivity : K9Activity() {
private lateinit var navController: NavController
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setLayout(R.layout.activity_add_account)
initializeActionBar()
}
private fun initializeActionBar() {
val appBarConfiguration = AppBarConfiguration(topLevelDestinationIds = setOf(R.id.addJmapAccountScreen))
navController = findNavController(R.id.nav_host_fragment)
setupActionBarWithNavController(navController, appBarConfiguration)
}
override fun onSupportNavigateUp(): Boolean {
return navController.navigateUp() || super.onSupportNavigateUp()
}
}

View file

@ -0,0 +1,39 @@
package com.fsck.k9.ui.addaccount
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import com.fsck.k9.jmap.R
import com.fsck.k9.jmap.databinding.FragmentAddAccountBinding
import com.fsck.k9.ui.observeNotNull
import org.koin.androidx.viewmodel.ext.android.viewModel
class AddAccountFragment : Fragment() {
private val viewModel: AddAccountViewModel by viewModel()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.getActionEvents().observeNotNull(this) { handleActionEvents(it) }
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val binding = FragmentAddAccountBinding.inflate(inflater, container, false)
binding.lifecycleOwner = this
binding.viewModel = viewModel
return binding.root
}
private fun handleActionEvents(action: Action) {
when (action) {
is Action.GoToMessageList -> goToMessageList()
}
}
private fun goToMessageList() {
findNavController().navigate(R.id.action_addJmapAccountScreen_to_messageListScreen)
}
}

View file

@ -0,0 +1,157 @@
package com.fsck.k9.ui.addaccount
import androidx.annotation.StringRes
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fsck.k9.EmailAddressValidator
import com.fsck.k9.backend.jmap.JmapAccountDiscovery
import com.fsck.k9.backend.jmap.JmapDiscoveryResult
import com.fsck.k9.backend.jmap.JmapDiscoveryResult.JmapAccount
import com.fsck.k9.backends.JmapAccountCreator
import com.fsck.k9.helper.SingleLiveEvent
import com.fsck.k9.helper.measureRealtimeMillisWithResult
import com.fsck.k9.jmap.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class AddAccountViewModel(
private val emailAddressValidator: EmailAddressValidator,
private val jmapAccountDiscovery: JmapAccountDiscovery,
private val jmapAccountCreator: JmapAccountCreator
) : ViewModel() {
val emailAddress = MutableLiveData<String>()
val emailAddressError = MutableLiveData<Int?>()
val password = MutableLiveData<String>()
val passwordError = MutableLiveData<Int?>()
val setupErrorText: MutableLiveData<Int> = createMutableLiveData(R.string.empty_string)
val isInputEnabled: MutableLiveData<Boolean> = createMutableLiveData(true)
val isNextButtonEnabled: MutableLiveData<Boolean> = createMutableLiveData(true)
val isProgressBarVisible: MutableLiveData<Boolean> = createMutableLiveData(false)
private val actionLiveData = SingleLiveEvent<Action>()
init {
Transformations.distinctUntilChanged(emailAddress).observeForever { resetEmailAddressError() }
Transformations.distinctUntilChanged(password).observeForever { resetPasswordError() }
}
fun getActionEvents(): LiveData<Action> = actionLiveData
fun onNextButtonClicked() {
discoverServerSettings()
}
private fun discoverServerSettings() {
val emailAddress = this.emailAddress.value?.trim() ?: ""
val password = this.password.value ?: ""
if (!emailAddressValidator.isValidAddressOnly(emailAddress)) {
displayEmailAddressError(R.string.add_account__email_address_error)
return
}
showDiscoveryProgressBar()
viewModelScope.launch {
val (elapsed, discoveryResult) = measureRealtimeMillisWithResult {
withContext(Dispatchers.IO) {
jmapAccountDiscovery.discover(emailAddress, password)
}
}
if (elapsed < MIN_PROGRESS_DURATION) {
delay(MIN_PROGRESS_DURATION - elapsed)
}
if (discoveryResult is JmapAccount) {
createAccount(emailAddress, password, discoveryResult)
} else {
displayDiscoveryError(discoveryResult)
hideDiscoveryProgressBar()
}
}
}
private suspend fun createAccount(emailAddress: String, password: String, jmapAccount: JmapAccount) {
GlobalScope.launch(Dispatchers.IO) {
jmapAccountCreator.createAccount(emailAddress, password, jmapAccount)
}.join()
sendActionEvent(Action.GoToMessageList)
}
private fun displayDiscoveryError(discoveryResult: JmapDiscoveryResult) {
when (discoveryResult) {
is JmapDiscoveryResult.GenericFailure -> {
displayError(R.string.add_account__generic_failure)
}
is JmapDiscoveryResult.NoEmailAccountFoundFailure -> {
displayError(R.string.add_account__no_email_account_found)
}
is JmapDiscoveryResult.AuthenticationFailure -> {
displayPasswordError(R.string.add_account__password_error)
}
is JmapDiscoveryResult.EndpointNotFoundFailure -> {
displayError(R.string.add_account__jmap_server_not_found)
}
}
}
@Suppress("SameParameterValue")
private fun displayEmailAddressError(@StringRes error: Int) {
emailAddressError.value = error
}
private fun resetEmailAddressError() {
emailAddressError.value = null
}
@Suppress("SameParameterValue")
private fun displayPasswordError(@StringRes error: Int) {
passwordError.value = error
}
private fun resetPasswordError() {
passwordError.value = null
}
private fun showDiscoveryProgressBar() {
isInputEnabled.value = false
isProgressBarVisible.value = true
isNextButtonEnabled.value = false
setupErrorText.value = R.string.empty_string
}
private fun hideDiscoveryProgressBar() {
isInputEnabled.value = true
isProgressBarVisible.value = false
isNextButtonEnabled.value = true
}
private fun displayError(@StringRes error: Int) {
setupErrorText.value = error
}
private fun sendActionEvent(action: Action) {
actionLiveData.value = action
}
private fun <T> createMutableLiveData(initialValue: T): MutableLiveData<T> {
return MutableLiveData<T>().apply {
value = initialValue
}
}
companion object {
private const val MIN_PROGRESS_DURATION = 500
}
}
sealed class Action {
object GoToMessageList : Action()
}

View file

@ -0,0 +1,8 @@
package com.fsck.k9.ui.addaccount
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
val uiAddAccountModule = module {
viewModel { AddAccountViewModel(get(), get(), get()) }
}

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<include layout="@layout/toolbar" />
<fragment
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
app:defaultNavHost="true"
app:navGraph="@navigation/navigation_add_account" />
</LinearLayout>

View file

@ -0,0 +1,117 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="viewModel"
type="com.fsck.k9.ui.addaccount.AddAccountViewModel" />
</data>
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:fitsSystemWindows="true">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/emailAddressLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
app:error="@{viewModel.emailAddressError}"
app:errorEnabled="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/emailAddress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:enabled="@{viewModel.isInputEnabled}"
android:hint="@string/account_setup_basics_email_hint"
android:imeOptions="flagNoExtractUi"
android:inputType="textEmailAddress"
android:text="@={viewModel.emailAddress}" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/emailPasswordLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
app:error="@{viewModel.passwordError}"
app:errorEnabled="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/emailAddressLayout"
app:passwordToggleEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/emailPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:enabled="@{viewModel.isInputEnabled}"
android:hint="@string/account_setup_basics_password_hint"
android:imeOptions="flagNoExtractUi"
android:inputType="textPassword"
android:text="@={viewModel.password}" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/setupError"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:text="@{viewModel.setupErrorText}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/emailPasswordLayout" />
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="32dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="32dp"
app:isVisible="@{viewModel.isProgressBarVisible()}"
app:layout_constraintBottom_toTopOf="@+id/nextButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/setupError" />
<Button
android:id="@+id/nextButton"
style="@style/Widget.AppCompat.Button.Colored"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:enabled="@{viewModel.isNextButtonEnabled()}"
android:onClick="@{() -> viewModel.onNextButtonClicked()}"
android:text="@string/next_action"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/emailPasswordLayout"
app:layout_constraintVertical_bias="1.0" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</layout>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/navigation_add_account"
app:startDestination="@id/addJmapAccountScreen">
<fragment
android:id="@+id/addJmapAccountScreen"
android:name="com.fsck.k9.ui.addaccount.AddAccountFragment"
android:label="@string/add_account_action"
tools:layout="@layout/fragment_add_account">
<action
android:id="@+id/action_addJmapAccountScreen_to_messageListScreen"
app:destination="@id/messageListScreen" />
</fragment>
<activity
android:id="@+id/messageListScreen"
android:name="com.fsck.k9.activity.MessageList"
tools:layout="@layout/message_list"/>
</navigation>

View file

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/navigation_onboarding"
app:startDestination="@id/welcomeScreen">
<fragment
android:id="@+id/welcomeScreen"
android:name="com.fsck.k9.ui.onboarding.WelcomeFragment"
android:label="@string/welcome_message_title"
tools:layout="@layout/fragment_welcome_message">
<action
android:id="@+id/action_welcomeScreen_to_settingsImportScreen"
app:destination="@id/settingsImportScreen" />
<action
android:id="@+id/action_welcomeScreen_to_addAccountScreen"
app:destination="@id/addAccountScreen" />
<action
android:id="@+id/action_welcomeScreen_to_messageListScreen"
app:destination="@id/messageListScreen" />
</fragment>
<fragment
android:id="@+id/settingsImportScreen"
android:name="com.fsck.k9.ui.settings.import.SettingsImportFragment"
android:label="@string/settings_import_title"
tools:layout="@layout/fragment_settings_import"/>
<activity
android:id="@+id/addAccountScreen"
android:name="com.fsck.k9.ui.addaccount.AddAccountActivity"
android:label="@string/add_account_action"
tools:layout="@layout/activity_add_account"/>
<activity
android:id="@+id/messageListScreen"
android:name="com.fsck.k9.activity.MessageList"
tools:layout="@layout/message_list"/>
</navigation>

View file

@ -0,0 +1,11 @@
<?xml version='1.0' encoding='UTF-8'?>
<resources>
<string name="app_name">K-9 JMAP</string>
<string name="empty_string" translatable="false" />
<string name="add_account__email_address_error">Please enter a valid email address</string>
<string name="add_account__password_error">"Couldn't sign in. Please make sure your password is correct."</string>
<string name="add_account__generic_failure">Unknown error while discovering server settings</string>
<string name="add_account__no_email_account_found">"Couldn't find an email account on the JMAP server"</string>
<string name="add_account__jmap_server_not_found">"Couldn't find the JMAP server from your email address"</string>
</resources>

View file

@ -0,0 +1,37 @@
package com.fsck.k9
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import com.fsck.k9.ui.endtoend.AutocryptKeyTransferActivity
import com.fsck.k9.ui.endtoend.AutocryptKeyTransferPresenter
import com.nhaarman.mockito_kotlin.doReturn
import com.nhaarman.mockito_kotlin.mock
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.KoinApplication
import org.koin.core.logger.PrintLogger
import org.koin.core.parameter.parametersOf
import org.koin.test.AutoCloseKoinTest
import org.koin.test.check.checkModules
import org.openintents.openpgp.OpenPgpApiManager
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(application = App::class)
class DependencyInjectionTest : AutoCloseKoinTest() {
val lifecycleOwner = mock<LifecycleOwner> {
on { lifecycle } doReturn mock<Lifecycle>()
}
val autocryptTransferView = mock<AutocryptKeyTransferActivity>()
@Test
fun testDependencyTree() {
KoinApplication.logger = PrintLogger()
getKoin().checkModules {
create<OpenPgpApiManager> { parametersOf(lifecycleOwner) }
create<AutocryptKeyTransferPresenter> { parametersOf(lifecycleOwner, autocryptTransferView) }
}
}
}

50
backend/jmap/build.gradle Normal file
View file

@ -0,0 +1,50 @@
apply plugin: 'com.android.library'
apply plugin: 'org.jetbrains.kotlin.android'
apply plugin: 'org.jlleitschuh.gradle.ktlint'
if (rootProject.testCoverage) {
apply plugin: 'jacoco'
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${versions.kotlin}"
api project(":backend:api")
implementation 'rs.ltt.jmap:jmap-client:0.2.2'
implementation "com.jakewharton.timber:timber:${versions.timber}"
testImplementation project(":mail:testing")
testImplementation "junit:junit:${versions.junit}"
testImplementation "org.mockito:mockito-core:${versions.mockito}"
testImplementation("com.squareup.okhttp3:mockwebserver:4.2.1")
}
android {
compileSdkVersion buildConfig.compileSdk
buildToolsVersion buildConfig.buildTools
defaultConfig {
minSdkVersion buildConfig.minSdk
}
buildTypes {
debug {
testCoverageEnabled rootProject.testCoverage
}
}
lintOptions {
abortOnError false
lintConfig file("$rootProject.projectDir/config/lint/lint.xml")
}
compileOptions {
sourceCompatibility javaVersion
targetCompatibility javaVersion
}
kotlinOptions {
jvmTarget = kotlinJvmVersion
}
}

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.fsck.k9.backend.jmap" />

View file

@ -0,0 +1,157 @@
package com.fsck.k9.backend.jmap
import com.fsck.k9.backend.api.BackendStorage
import com.fsck.k9.backend.api.FolderInfo
import com.fsck.k9.mail.AuthenticationFailedException
import com.fsck.k9.mail.FolderType
import com.fsck.k9.mail.MessagingException
import java.lang.Exception
import rs.ltt.jmap.client.JmapClient
import rs.ltt.jmap.client.api.ErrorResponseException
import rs.ltt.jmap.client.api.InvalidSessionResourceException
import rs.ltt.jmap.client.api.MethodErrorResponseException
import rs.ltt.jmap.client.api.UnauthorizedException
import rs.ltt.jmap.common.Request.Invocation.ResultReference
import rs.ltt.jmap.common.entity.Mailbox
import rs.ltt.jmap.common.entity.Role
import rs.ltt.jmap.common.method.call.mailbox.ChangesMailboxMethodCall
import rs.ltt.jmap.common.method.call.mailbox.GetMailboxMethodCall
import rs.ltt.jmap.common.method.response.mailbox.ChangesMailboxMethodResponse
import rs.ltt.jmap.common.method.response.mailbox.GetMailboxMethodResponse
internal class CommandRefreshFolderList(
private val backendStorage: BackendStorage,
private val jmapClient: JmapClient,
private val accountId: String
) {
fun refreshFolderList() {
try {
val state = backendStorage.getExtraString(STATE)
if (state == null) {
fetchMailboxes()
} else {
fetchMailboxUpdates(state)
}
} catch (e: UnauthorizedException) {
throw AuthenticationFailedException("Authentication failed", e)
} catch (e: InvalidSessionResourceException) {
throw MessagingException(e.message, true, e)
} catch (e: ErrorResponseException) {
throw MessagingException(e.message, true, e)
} catch (e: MethodErrorResponseException) {
throw MessagingException(e.message, e.isPermanentError, e)
} catch (e: Exception) {
throw MessagingException(e)
}
}
private fun fetchMailboxes() {
val call = jmapClient.call(GetMailboxMethodCall(accountId))
val response = call.getMainResponseBlocking<GetMailboxMethodResponse>()
val foldersOnServer = response.list
val oldFolderServerIds = backendStorage.getFolderServerIds()
val (foldersToUpdate, foldersToCreate) = foldersOnServer.partition { it.id in oldFolderServerIds }
for (folder in foldersToUpdate) {
backendStorage.changeFolder(folder.id, folder.name, folder.type)
}
val newFolders = foldersToCreate.map { folder ->
FolderInfo(folder.id, folder.name, folder.type)
}
backendStorage.createFolders(newFolders)
val newFolderServerIds = foldersOnServer.map { it.id }
val removedFolderServerIds = oldFolderServerIds - newFolderServerIds
backendStorage.deleteFolders(removedFolderServerIds)
backendStorage.setExtraString(STATE, response.state)
}
private fun fetchMailboxUpdates(state: String) {
try {
fetchAllMailboxChanges(state)
} catch (e: MethodErrorResponseException) {
if (e.methodErrorResponse.type == ERROR_CANNOT_CALCULATE_CHANGES) {
fetchMailboxes()
} else {
throw e
}
}
}
private fun fetchAllMailboxChanges(state: String) {
var currentState = state
do {
val (newState, hasMoreChanges) = fetchMailboxChanges(currentState)
currentState = newState
} while (hasMoreChanges)
}
private fun fetchMailboxChanges(state: String): UpdateState {
val multiCall = jmapClient.newMultiCall()
val mailboxChangesCall = multiCall.call(ChangesMailboxMethodCall(accountId, state))
val createdMailboxesCall = multiCall.call(
GetMailboxMethodCall(
accountId,
mailboxChangesCall.createResultReference(ResultReference.Path.CREATED)
)
)
val changedMailboxesCall = multiCall.call(
GetMailboxMethodCall(
accountId,
mailboxChangesCall.createResultReference(ResultReference.Path.UPDATED),
mailboxChangesCall.createResultReference(ResultReference.Path.UPDATED_PROPERTIES)
)
)
multiCall.execute()
val mailboxChangesResponse = mailboxChangesCall.getMainResponseBlocking<ChangesMailboxMethodResponse>()
val createdMailboxResponse = createdMailboxesCall.getMainResponseBlocking<GetMailboxMethodResponse>()
val changedMailboxResponse = changedMailboxesCall.getMainResponseBlocking<GetMailboxMethodResponse>()
val foldersToCreate = createdMailboxResponse.list.map { folder ->
FolderInfo(folder.id, folder.name, folder.type)
}
backendStorage.createFolders(foldersToCreate)
for (folder in changedMailboxResponse.list) {
backendStorage.changeFolder(folder.id, folder.name, folder.type)
}
val destroyed = mailboxChangesResponse.destroyed
destroyed?.let {
backendStorage.deleteFolders(it.toList())
}
backendStorage.setExtraString(STATE, mailboxChangesResponse.newState)
return UpdateState(
state = mailboxChangesResponse.newState,
hasMoreChanges = mailboxChangesResponse.isHasMoreChanges
)
}
private val Mailbox.type: FolderType
get() = when (role) {
Role.INBOX -> FolderType.INBOX
Role.ARCHIVE -> FolderType.ARCHIVE
Role.DRAFTS -> FolderType.DRAFTS
Role.SENT -> FolderType.SENT
Role.TRASH -> FolderType.TRASH
Role.JUNK -> FolderType.SPAM
else -> FolderType.REGULAR
}
private val MethodErrorResponseException.isPermanentError: Boolean
get() = methodErrorResponse.type != ERROR_SERVER_UNAVAILABLE
companion object {
private const val STATE = "jmapState"
private const val ERROR_SERVER_UNAVAILABLE = "serverUnavailable"
private const val ERROR_CANNOT_CALCULATE_CHANGES = "cannotCalculateChanges"
}
private data class UpdateState(val state: String, val hasMoreChanges: Boolean)
}

View file

@ -0,0 +1,46 @@
package com.fsck.k9.backend.jmap
import java.net.UnknownHostException
import rs.ltt.jmap.client.JmapClient
import rs.ltt.jmap.client.api.EndpointNotFoundException
import rs.ltt.jmap.client.api.UnauthorizedException
import rs.ltt.jmap.common.entity.capability.MailAccountCapability
import timber.log.Timber
class JmapAccountDiscovery {
fun discover(emailAddress: String, password: String): JmapDiscoveryResult {
val jmapClient = JmapClient(emailAddress, password)
val session = try {
jmapClient.session.futureGetOrThrow()
} catch (e: EndpointNotFoundException) {
return JmapDiscoveryResult.EndpointNotFoundFailure
} catch (e: UnknownHostException) {
return JmapDiscoveryResult.EndpointNotFoundFailure
} catch (e: UnauthorizedException) {
return JmapDiscoveryResult.AuthenticationFailure
} catch (e: Exception) {
Timber.e(e, "Unable to get JMAP session")
return JmapDiscoveryResult.GenericFailure(e)
}
val accounts = session.getAccounts(MailAccountCapability::class.java)
val accountId = when {
accounts.isEmpty() -> return JmapDiscoveryResult.NoEmailAccountFoundFailure
accounts.size == 1 -> accounts.keys.first()
else -> session.getPrimaryAccount(MailAccountCapability::class.java)
}
val account = accounts[accountId]!!
val accountName = account.name ?: emailAddress
return JmapDiscoveryResult.JmapAccount(accountId, accountName)
}
}
sealed class JmapDiscoveryResult {
class GenericFailure(val cause: Throwable) : JmapDiscoveryResult()
object EndpointNotFoundFailure : JmapDiscoveryResult()
object AuthenticationFailure : JmapDiscoveryResult()
object NoEmailAccountFoundFailure : JmapDiscoveryResult()
data class JmapAccount(val accountId: String, val name: String) : JmapDiscoveryResult()
}

View file

@ -0,0 +1,127 @@
package com.fsck.k9.backend.jmap
import com.fsck.k9.backend.api.Backend
import com.fsck.k9.backend.api.BackendStorage
import com.fsck.k9.backend.api.SyncConfig
import com.fsck.k9.backend.api.SyncListener
import com.fsck.k9.mail.BodyFactory
import com.fsck.k9.mail.FetchProfile
import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.Message
import com.fsck.k9.mail.Part
import com.fsck.k9.mail.PushReceiver
import com.fsck.k9.mail.Pusher
import okhttp3.HttpUrl
import rs.ltt.jmap.client.JmapClient
import rs.ltt.jmap.common.method.call.core.EchoMethodCall
class JmapBackend(
private val backendStorage: BackendStorage,
config: JmapConfig
) : Backend {
private val jmapClient = config.toJmapClient()
private val accountId = config.accountId
private val commandRefreshFolderList = CommandRefreshFolderList(backendStorage, jmapClient, accountId)
override val supportsSeenFlag = true
override val supportsExpunge = false
override val supportsMove = true
override val supportsCopy = true
override val supportsUpload = true
override val supportsTrashFolder = true
override val supportsSearchByDate = true
override val isPushCapable = false // FIXME
override val isDeleteMoveToTrash = false
override fun refreshFolderList() {
commandRefreshFolderList.refreshFolderList()
}
override fun sync(folder: String, syncConfig: SyncConfig, listener: SyncListener) {
// TODO: implement
}
override fun downloadMessage(syncConfig: SyncConfig, folderServerId: String, messageServerId: String) {
throw UnsupportedOperationException("not implemented")
}
override fun setFlag(folderServerId: String, messageServerIds: List<String>, flag: Flag, newState: Boolean) {
throw UnsupportedOperationException("not implemented")
}
override fun markAllAsRead(folderServerId: String) {
throw UnsupportedOperationException("not implemented")
}
override fun expunge(folderServerId: String) {
throw UnsupportedOperationException("not implemented")
}
override fun expungeMessages(folderServerId: String, messageServerIds: List<String>) {
throw UnsupportedOperationException("not implemented")
}
override fun deleteMessages(folderServerId: String, messageServerIds: List<String>) {
throw UnsupportedOperationException("not implemented")
}
override fun deleteAllMessages(folderServerId: String) {
throw UnsupportedOperationException("not implemented")
}
override fun moveMessages(sourceFolderServerId: String, targetFolderServerId: String, messageServerIds: List<String>): Map<String, String>? {
throw UnsupportedOperationException("not implemented")
}
override fun moveMessagesAndMarkAsRead(sourceFolderServerId: String, targetFolderServerId: String, messageServerIds: List<String>): Map<String, String>? {
throw UnsupportedOperationException("not implemented")
}
override fun copyMessages(sourceFolderServerId: String, targetFolderServerId: String, messageServerIds: List<String>): Map<String, String>? {
throw UnsupportedOperationException("not implemented")
}
override fun search(folderServerId: String, query: String?, requiredFlags: Set<Flag>?, forbiddenFlags: Set<Flag>?): List<String> {
throw UnsupportedOperationException("not implemented")
}
override fun fetchMessage(folderServerId: String, messageServerId: String, fetchProfile: FetchProfile): Message {
throw UnsupportedOperationException("not implemented")
}
override fun fetchPart(folderServerId: String, messageServerId: String, part: Part, bodyFactory: BodyFactory) {
throw UnsupportedOperationException("not implemented")
}
override fun findByMessageId(folderServerId: String, messageId: String): String? {
throw UnsupportedOperationException("not implemented")
}
override fun uploadMessage(folderServerId: String, message: Message): String? {
throw UnsupportedOperationException("not implemented")
}
override fun createPusher(receiver: PushReceiver): Pusher {
throw UnsupportedOperationException("not implemented")
}
override fun checkIncomingServerSettings() {
jmapClient.call(EchoMethodCall()).get()
}
override fun sendMessage(message: Message) {
throw UnsupportedOperationException("not implemented")
}
override fun checkOutgoingServerSettings() {
checkIncomingServerSettings()
}
private fun JmapConfig.toJmapClient(): JmapClient {
return if (baseUrl == null) {
JmapClient(username, password)
} else {
val baseHttpUrl = HttpUrl.parse(baseUrl)
JmapClient(username, password, baseHttpUrl)
}
}
}

View file

@ -0,0 +1,8 @@
package com.fsck.k9.backend.jmap
data class JmapConfig(
val username: String,
val password: String,
val baseUrl: String?,
val accountId: String
)

View file

@ -0,0 +1,23 @@
package com.fsck.k9.backend.jmap
import com.google.common.util.concurrent.ListenableFuture
import java.util.concurrent.ExecutionException
import rs.ltt.jmap.client.JmapRequest
import rs.ltt.jmap.client.MethodResponses
import rs.ltt.jmap.common.method.MethodResponse
internal inline fun <reified T : MethodResponse> ListenableFuture<MethodResponses>.getMainResponseBlocking(): T {
return futureGetOrThrow().getMain(T::class.java)
}
internal inline fun <reified T : MethodResponse> JmapRequest.Call.getMainResponseBlocking(): T {
return methodResponses.getMainResponseBlocking()
}
internal inline fun <T> ListenableFuture<T>.futureGetOrThrow(): T {
return try {
get()
} catch (e: ExecutionException) {
throw e.cause ?: e
}
}

View file

@ -0,0 +1,195 @@
package com.fsck.k9.backend.jmap
import com.fsck.k9.backend.api.FolderInfo
import com.fsck.k9.mail.AuthenticationFailedException
import com.fsck.k9.mail.FolderType
import com.fsck.k9.mail.MessagingException
import junit.framework.AssertionFailedError
import okhttp3.HttpUrl
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okio.buffer
import okio.source
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import rs.ltt.jmap.client.JmapClient
class CommandRefreshFolderListTest {
private val backendStorage = InMemoryBackendStorage()
@Test
fun sessionResourceWithAuthenticationError() {
val command = createCommandRefreshFolderList(
MockResponse().setResponseCode(401)
)
try {
command.refreshFolderList()
fail("Expected exception")
} catch (e: AuthenticationFailedException) {
}
}
@Test
fun invalidSessionResource() {
val command = createCommandRefreshFolderList(
MockResponse().setBody("invalid")
)
try {
command.refreshFolderList()
fail("Expected exception")
} catch (e: MessagingException) {
assertTrue(e.isPermanentFailure)
}
}
@Test
fun fetchMailboxes() {
val command = createCommandRefreshFolderList(
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
responseBodyFromResource("/jmap_responses/mailbox/mailbox_get.json")
)
command.refreshFolderList()
assertFolderList("id_inbox", "id_archive", "id_drafts", "id_sent", "id_trash", "id_folder1")
assertFolderPresent("id_inbox", "Inbox", FolderType.INBOX)
assertFolderPresent("id_archive", "Archive", FolderType.ARCHIVE)
assertFolderPresent("id_drafts", "Drafts", FolderType.DRAFTS)
assertFolderPresent("id_sent", "Sent", FolderType.SENT)
assertFolderPresent("id_trash", "Trash", FolderType.TRASH)
assertFolderPresent("id_folder1", "folder1", FolderType.REGULAR)
assertMailboxState("23")
}
@Test
fun fetchMailboxUpdates() {
val command = createCommandRefreshFolderList(
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
responseBodyFromResource("/jmap_responses/mailbox/mailbox_changes.json")
)
createFoldersInBackendStorage(state = "23")
command.refreshFolderList()
assertFolderList("id_inbox", "id_archive", "id_drafts", "id_sent", "id_trash", "id_folder2")
assertFolderPresent("id_inbox", "Inbox", FolderType.INBOX)
assertFolderPresent("id_archive", "Archive", FolderType.ARCHIVE)
assertFolderPresent("id_drafts", "Drafts", FolderType.DRAFTS)
assertFolderPresent("id_sent", "Sent", FolderType.SENT)
assertFolderPresent("id_trash", "Deleted messages", FolderType.TRASH)
assertFolderPresent("id_folder2", "folder2", FolderType.REGULAR)
assertMailboxState("42")
}
@Test
fun fetchMailboxUpdates_withHasMoreChanges() {
val command = createCommandRefreshFolderList(
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
responseBodyFromResource("/jmap_responses/mailbox/mailbox_changes_1.json"),
responseBodyFromResource("/jmap_responses/mailbox/mailbox_changes_2.json")
)
createFoldersInBackendStorage(state = "23")
command.refreshFolderList()
assertFolderList("id_inbox", "id_archive", "id_drafts", "id_sent", "id_trash", "id_folder2")
assertFolderPresent("id_inbox", "Inbox", FolderType.INBOX)
assertFolderPresent("id_archive", "Archive", FolderType.ARCHIVE)
assertFolderPresent("id_drafts", "Drafts", FolderType.DRAFTS)
assertFolderPresent("id_sent", "Sent", FolderType.SENT)
assertFolderPresent("id_trash", "Deleted messages", FolderType.TRASH)
assertFolderPresent("id_folder2", "folder2", FolderType.REGULAR)
assertMailboxState("42")
}
@Test
fun fetchMailboxUpdates_withCannotCalculateChangesError() {
val command = createCommandRefreshFolderList(
responseBodyFromResource("/jmap_responses/session/valid_session.json"),
responseBodyFromResource("/jmap_responses/mailbox/mailbox_changes_error_cannot_calculate_changes.json"),
responseBodyFromResource("/jmap_responses/mailbox/mailbox_get.json")
)
setMailboxState("unknownToServer")
command.refreshFolderList()
assertFolderList("id_inbox", "id_archive", "id_drafts", "id_sent", "id_trash", "id_folder1")
assertFolderPresent("id_inbox", "Inbox", FolderType.INBOX)
assertFolderPresent("id_archive", "Archive", FolderType.ARCHIVE)
assertFolderPresent("id_drafts", "Drafts", FolderType.DRAFTS)
assertFolderPresent("id_sent", "Sent", FolderType.SENT)
assertFolderPresent("id_trash", "Trash", FolderType.TRASH)
assertFolderPresent("id_folder1", "folder1", FolderType.REGULAR)
assertMailboxState("23")
}
private fun createCommandRefreshFolderList(vararg mockResponses: MockResponse): CommandRefreshFolderList {
val server = createMockWebServer(*mockResponses)
return createCommandRefreshFolderList(server.url("/jmap/"))
}
private fun createMockWebServer(vararg mockResponses: MockResponse): MockWebServer {
return MockWebServer().apply {
for (mockResponse in mockResponses) {
enqueue(mockResponse)
}
start()
}
}
private fun createCommandRefreshFolderList(
baseUrl: HttpUrl,
accountId: String = "test@example.com"
): CommandRefreshFolderList {
val jmapClient = JmapClient("test", "test", baseUrl)
return CommandRefreshFolderList(backendStorage, jmapClient, accountId)
}
private fun responseBodyFromResource(name: String): MockResponse {
return MockResponse().setBody(loadResource(name))
}
private fun loadResource(name: String): String {
val resourceAsStream = javaClass.getResourceAsStream(name) ?: error("Couldn't load resource: $name")
return resourceAsStream.use { it.source().buffer().readUtf8() }
}
@Suppress("SameParameterValue")
private fun createFoldersInBackendStorage(state: String) {
createFolderInBackendStorage("id_inbox", "Inbox", FolderType.INBOX)
createFolderInBackendStorage("id_archive", "Archive", FolderType.ARCHIVE)
createFolderInBackendStorage("id_drafts", "Drafts", FolderType.DRAFTS)
createFolderInBackendStorage("id_sent", "Sent", FolderType.SENT)
createFolderInBackendStorage("id_trash", "Trash", FolderType.TRASH)
createFolderInBackendStorage("id_folder1", "folder1", FolderType.REGULAR)
setMailboxState(state)
}
private fun createFolderInBackendStorage(serverId: String, name: String, type: FolderType) {
backendStorage.createFolders(listOf(FolderInfo(serverId, name, type)))
}
private fun setMailboxState(state: String) {
backendStorage.setExtraString("jmapState", state)
}
private fun assertFolderList(vararg folderServerIds: String) {
assertEquals(folderServerIds.toSet(), backendStorage.getFolderServerIds().toSet())
}
private fun assertFolderPresent(serverId: String, name: String, type: FolderType) {
val folder = backendStorage.folders[serverId]
?: throw AssertionFailedError("Expected folder '$serverId' in BackendStorage")
assertEquals(name, folder.name)
assertEquals(type, folder.type)
}
private fun assertMailboxState(expected: String) {
assertEquals(expected, backendStorage.getExtraString("jmapState"))
}
}

View file

@ -0,0 +1,98 @@
package com.fsck.k9.backend.jmap
import com.fsck.k9.backend.api.BackendFolder
import com.fsck.k9.mail.Flag
import com.fsck.k9.mail.FolderType
import com.fsck.k9.mail.Message
import java.util.Date
class InMemoryBackendFolder(override var name: String, var type: FolderType) : BackendFolder {
val extraStrings: MutableMap<String, String> = mutableMapOf()
val extraNumbers: MutableMap<String, Long> = mutableMapOf()
override var visibleLimit: Int = 25
override fun getAllMessagesAndEffectiveDates(): Map<String, Long?> {
throw UnsupportedOperationException("not implemented")
}
override fun destroyMessages(messageServerIds: List<String>) {
throw UnsupportedOperationException("not implemented")
}
override fun getLastUid(): Long? {
throw UnsupportedOperationException("not implemented")
}
override fun getMoreMessages(): BackendFolder.MoreMessages {
throw UnsupportedOperationException("not implemented")
}
override fun setMoreMessages(moreMessages: BackendFolder.MoreMessages) {
throw UnsupportedOperationException("not implemented")
}
override fun getUnreadMessageCount(): Int {
throw UnsupportedOperationException("not implemented")
}
override fun setLastChecked(timestamp: Long) {
throw UnsupportedOperationException("not implemented")
}
override fun setStatus(status: String?) {
throw UnsupportedOperationException("not implemented")
}
override fun getPushState(): String? {
throw UnsupportedOperationException("not implemented")
}
override fun setPushState(pushState: String?) {
throw UnsupportedOperationException("not implemented")
}
override fun isMessagePresent(messageServerId: String): Boolean {
throw UnsupportedOperationException("not implemented")
}
override fun getMessageFlags(messageServerId: String): Set<Flag> {
throw UnsupportedOperationException("not implemented")
}
override fun setMessageFlag(messageServerId: String, flag: Flag, value: Boolean) {
throw UnsupportedOperationException("not implemented")
}
override fun savePartialMessage(message: Message) {
throw UnsupportedOperationException("not implemented")
}
override fun saveCompleteMessage(message: Message) {
throw UnsupportedOperationException("not implemented")
}
override fun getLatestOldMessageSeenTime(): Date {
throw UnsupportedOperationException("not implemented")
}
override fun setLatestOldMessageSeenTime(date: Date) {
throw UnsupportedOperationException("not implemented")
}
override fun getOldestMessageDate(): Date? {
throw UnsupportedOperationException("not implemented")
}
override fun getFolderExtraString(name: String): String? = extraStrings[name]
override fun setFolderExtraString(name: String, value: String) {
extraStrings[name] = value
}
override fun getFolderExtraNumber(name: String): Long? = extraNumbers[name]
override fun setFolderExtraNumber(name: String, value: Long) {
extraNumbers[name] = value
}
}

View file

@ -0,0 +1,52 @@
package com.fsck.k9.backend.jmap
import com.fsck.k9.backend.api.BackendFolder
import com.fsck.k9.backend.api.BackendStorage
import com.fsck.k9.backend.api.FolderInfo
import com.fsck.k9.mail.FolderType
class InMemoryBackendStorage : BackendStorage {
val folders: MutableMap<String, InMemoryBackendFolder> = mutableMapOf()
val extraStrings: MutableMap<String, String> = mutableMapOf()
val extraNumbers: MutableMap<String, Long> = mutableMapOf()
override fun getFolder(folderServerId: String): BackendFolder {
return folders[folderServerId] ?: error("Folder $folderServerId not found")
}
override fun getFolderServerIds(): List<String> {
return folders.keys.toList()
}
override fun createFolders(folders: List<FolderInfo>) {
folders.forEach { folder ->
if (this.folders.containsKey(folder.serverId)) error("Folder ${folder.serverId} already present")
this.folders[folder.serverId] = InMemoryBackendFolder(folder.name, folder.type)
}
}
override fun deleteFolders(folderServerIds: List<String>) {
for (folderServerId in folderServerIds) {
folders.remove(folderServerId) ?: error("Folder $folderServerId not found")
}
}
override fun changeFolder(folderServerId: String, name: String, type: FolderType) {
val folder = folders[folderServerId] ?: error("Folder $folderServerId not found")
folder.name = name
folder.type = type
}
override fun getExtraString(name: String): String? = extraStrings[name]
override fun setExtraString(name: String, value: String) {
extraStrings[name] = value
}
override fun getExtraNumber(name: String): Long? = extraNumbers[name]
override fun setExtraNumber(name: String, value: Long) {
extraNumbers[name] = value
}
}

View file

@ -0,0 +1,86 @@
{
"methodResponses": [
[
"Mailbox/changes",
{
"accountId": "test@example.com",
"oldState": "23",
"newState": "42",
"hasMoreChanges": false,
"created": [ "id_folder2" ],
"updated": [ "id_trash" ],
"destroyed": [ "id_folder1" ]
},
"0"
],
[
"Mailbox/get",
{
"accountId": "test@example.com",
"state": "42",
"list": [
{
"id": "id_folder2",
"name": "folder2",
"parentId": null,
"myRights": {
"mayReadItems": true,
"mayAddItems": true,
"mayRemoveItems": true,
"mayCreateChild": true,
"mayDelete": true,
"maySubmit": true,
"maySetSeen": true,
"maySetKeywords": true,
"mayAdmin": true,
"mayRename": true
},
"role": null,
"totalEmails": 0,
"unreadEmails": 0,
"totalThreads": 0,
"unreadThreads": 0,
"sortOrder": 10,
"isSubscribed": false
}
]
},
"1"
],
[
"Mailbox/get",
{
"accountId": "test@example.com",
"state": "42",
"list": [
{
"id": "id_trash",
"name": "Deleted messages",
"parentId": null,
"myRights": {
"mayReadItems": true,
"mayAddItems": true,
"mayRemoveItems": true,
"mayCreateChild": true,
"mayDelete": true,
"maySubmit": true,
"maySetSeen": true,
"maySetKeywords": true,
"mayAdmin": true,
"mayRename": true
},
"role": "trash",
"totalEmails": 2,
"unreadEmails": 0,
"totalThreads": 2,
"unreadThreads": 0,
"sortOrder": 7,
"isSubscribed": false
}
]
},
"2"
]
],
"sessionState": "0"
}

View file

@ -0,0 +1,61 @@
{
"methodResponses": [
[
"Mailbox/changes",
{
"accountId": "test@example.com",
"oldState": "23",
"newState": "27",
"hasMoreChanges": true,
"created": [ "id_folder2" ],
"updated": [],
"destroyed": []
},
"0"
],
[
"Mailbox/get",
{
"accountId": "test@example.com",
"state": "27",
"list": [
{
"id": "id_folder2",
"name": "folder2",
"parentId": null,
"myRights": {
"mayReadItems": true,
"mayAddItems": true,
"mayRemoveItems": true,
"mayCreateChild": true,
"mayDelete": true,
"maySubmit": true,
"maySetSeen": true,
"maySetKeywords": true,
"mayAdmin": true,
"mayRename": true
},
"role": null,
"totalEmails": 0,
"unreadEmails": 0,
"totalThreads": 0,
"unreadThreads": 0,
"sortOrder": 10,
"isSubscribed": false
}
]
},
"1"
],
[
"Mailbox/get",
{
"accountId": "test@example.com",
"state": "27",
"list": []
},
"2"
]
],
"sessionState": "0"
}

View file

@ -0,0 +1,61 @@
{
"methodResponses": [
[
"Mailbox/changes",
{
"accountId": "test@example.com",
"oldState": "27",
"newState": "42",
"hasMoreChanges": false,
"created": [],
"updated": [ "id_trash" ],
"destroyed": [ "id_folder1" ]
},
"0"
],
[
"Mailbox/get",
{
"accountId": "test@example.com",
"state": "42",
"list": []
},
"1"
],
[
"Mailbox/get",
{
"accountId": "test@example.com",
"state": "42",
"list": [
{
"id": "id_trash",
"name": "Deleted messages",
"parentId": null,
"myRights": {
"mayReadItems": true,
"mayAddItems": true,
"mayRemoveItems": true,
"mayCreateChild": true,
"mayDelete": true,
"maySubmit": true,
"maySetSeen": true,
"maySetKeywords": true,
"mayAdmin": true,
"mayRename": true
},
"role": "trash",
"totalEmails": 2,
"unreadEmails": 0,
"totalThreads": 2,
"unreadThreads": 0,
"sortOrder": 7,
"isSubscribed": false
}
]
},
"2"
]
],
"sessionState": "0"
}

View file

@ -0,0 +1,26 @@
{
"methodResponses": [
[
"error",
{
"type": "cannotCalculateChanges"
},
"0"
],
[
"error",
{
"type": "resultReference"
},
"1"
],
[
"error",
{
"type": "resultReference"
},
"2"
]
],
"sessionState": "0"
}

View file

@ -0,0 +1,159 @@
{
"methodResponses": [
[
"Mailbox/get",
{
"accountId": "test@example.com",
"state": "23",
"list": [
{
"id": "id_inbox",
"name": "Inbox",
"parentId": null,
"myRights": {
"mayReadItems": true,
"mayAddItems": true,
"mayRemoveItems": true,
"mayCreateChild": true,
"mayDelete": false,
"maySubmit": true,
"maySetSeen": true,
"maySetKeywords": true,
"mayAdmin": true,
"mayRename": false
},
"role": "inbox",
"totalEmails": 238,
"unreadEmails": 6,
"totalThreads": 80,
"unreadThreads": 4,
"sortOrder": 1,
"isSubscribed": false
},
{
"id": "id_archive",
"name": "Archive",
"parentId": null,
"myRights": {
"mayReadItems": true,
"mayAddItems": true,
"mayRemoveItems": true,
"mayCreateChild": true,
"mayDelete": true,
"maySubmit": true,
"maySetSeen": true,
"maySetKeywords": true,
"mayAdmin": true,
"mayRename": true
},
"role": "archive",
"totalEmails": 295,
"unreadEmails": 36,
"totalThreads": 136,
"unreadThreads": 17,
"sortOrder": 3,
"isSubscribed": false
},
{
"id": "id_drafts",
"name": "Drafts",
"parentId": null,
"myRights": {
"mayReadItems": true,
"mayAddItems": true,
"mayRemoveItems": true,
"mayCreateChild": true,
"mayDelete": true,
"maySubmit": true,
"maySetSeen": true,
"maySetKeywords": true,
"mayAdmin": true,
"mayRename": true
},
"role": "drafts",
"totalEmails": 0,
"unreadEmails": 0,
"totalThreads": 0,
"unreadThreads": 0,
"sortOrder": 4,
"isSubscribed": false
},
{
"id": "id_sent",
"name": "Sent",
"parentId": null,
"myRights": {
"mayReadItems": true,
"mayAddItems": true,
"mayRemoveItems": true,
"mayCreateChild": true,
"mayDelete": true,
"maySubmit": true,
"maySetSeen": true,
"maySetKeywords": true,
"mayAdmin": true,
"mayRename": true
},
"role": "sent",
"totalEmails": 2,
"unreadEmails": 0,
"totalThreads": 2,
"unreadThreads": 0,
"sortOrder": 5,
"isSubscribed": false
},
{
"id": "id_trash",
"name": "Trash",
"parentId": null,
"myRights": {
"mayReadItems": true,
"mayAddItems": true,
"mayRemoveItems": true,
"mayCreateChild": true,
"mayDelete": true,
"maySubmit": true,
"maySetSeen": true,
"maySetKeywords": true,
"mayAdmin": true,
"mayRename": true
},
"role": "trash",
"totalEmails": 2,
"unreadEmails": 0,
"totalThreads": 2,
"unreadThreads": 0,
"sortOrder": 7,
"isSubscribed": false
},
{
"id": "id_folder1",
"name": "folder1",
"parentId": null,
"myRights": {
"mayReadItems": true,
"mayAddItems": true,
"mayRemoveItems": true,
"mayCreateChild": true,
"mayDelete": true,
"maySubmit": true,
"maySetSeen": true,
"maySetKeywords": true,
"mayAdmin": true,
"mayRename": true
},
"role": null,
"totalEmails": 0,
"unreadEmails": 0,
"totalThreads": 0,
"unreadThreads": 0,
"sortOrder": 10,
"isSubscribed": false
}
]
},
"0"
]
],
"sessionState": "0"
}

View file

@ -0,0 +1,64 @@
{
"username": "test",
"apiUrl": "/jmap/",
"downloadUrl": "/jmap/download/{accountId}/{blobId}/{name}?accept={type}",
"uploadUrl": "/jmap/upload/{accountId}/",
"eventSourceUrl": "/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}",
"accounts": {
"test@example.com": {
"name": "test@example.com",
"isPersonal": true,
"isReadOnly": false,
"accountCapabilities": {
"urn:ietf:params:jmap:core": {},
"urn:ietf:params:jmap:submission": {
"maxDelayedSend": 44236800,
"submissionExtensions": {
"size": [
"10240000"
],
"dsn": []
}
},
"urn:ietf:params:jmap:mail": {
"emailQuerySortOptions": [
"receivedAt",
"sentAt",
"from",
"id",
"emailstate",
"size",
"subject",
"to",
"hasKeyword",
"someInThreadHaveKeyword",
"addedDates",
"threadSize",
"spamScore",
"snoozedUntil"
],
"maxKeywordsPerEmail": 100,
"maxSizeAttachmentsPerEmail": 10485760,
"maxMailboxesPerEmail": 20,
"mayCreateTopLevelMailbox": true,
"maxSizeMailboxName": 500
},
"urn:ietf:params:jmap:vacationresponse": {}
}
}
},
"capabilities": {
"urn:ietf:params:jmap:core": {
"maxSizeUpload": 1073741824,
"maxConcurrentUpload": 5,
"maxCallsInRequest": 50,
"maxObjectsInGet": 4096,
"maxObjectsInSet": 4096,
"collationAlgorithms": []
},
"urn:ietf:params:jmap:submission": {},
"urn:ietf:params:jmap:mail": {},
"urn:ietf:params:jmap:vacationresponse": {}
},
"state": "0"
}

View file

@ -12,7 +12,7 @@ buildscript {
'kotlin': '1.3.50',
'androidxAppCompat': '1.0.2',
'androidxRecyclerView': '1.0.0',
'androidxLifecycleExtensions': '2.0.0',
'androidxLifecycleExtensions': '2.1.0',
'androidxAnnotation': '1.0.1',
'androidxNavigation': '2.0.0',
'androidxConstraintLayout': '1.1.3',

View file

@ -1,4 +1,4 @@
android.useAndroidX=true
android.enableJetifier=true
org.gradle.jvmargs=-Xmx1g
org.gradle.jvmargs=-Xmx1400m
org.gradle.parallel=true

View file

@ -6,6 +6,10 @@ public class MessagingException extends Exception {
private boolean permanentFailure = false;
public MessagingException(Throwable cause) {
super(cause);
}
public MessagingException(String message) {
super(message);
}

View file

@ -1,4 +1,5 @@
include ':app:k9mail'
include ':app:k9mail-jmap'
include ':app:ui'
include ':app:core'
include ':app:storage'
@ -15,4 +16,5 @@ include ':backend:api'
include ':backend:imap'
include ':backend:pop3'
include ':backend:webdav'
include ':backend:jmap'
include ':plugins:openpgp-api-lib:openpgp-api'