Add UI to add a JMAP account to the app

This commit is contained in:
cketti 2020-01-15 16:07:42 +01:00
parent 0b21a7521d
commit 320cc8b40b
19 changed files with 606 additions and 7 deletions

View file

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

View file

@ -1,6 +1,7 @@
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) {
@ -21,6 +22,11 @@ dependencies {
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}"
@ -105,6 +111,10 @@ android {
jvmTarget = kotlinJvmVersion
}
dataBinding {
enabled = true
}
testOptions {
unitTests {
includeAndroidResources = true

View file

@ -242,6 +242,10 @@
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">

View file

@ -9,6 +9,7 @@ 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
@ -25,5 +26,6 @@ val appModules = listOf(
notificationModule,
resourcesModule,
backendsModule,
storageModule
storageModule,
uiAddAccountModule
)

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

@ -1,6 +1,7 @@
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 {
@ -17,4 +18,6 @@ val backendsModule = module {
single { Pop3BackendFactory(get(), get()) }
single { WebDavBackendFactory(get(), get()) }
single { JmapBackendFactory(get()) }
factory { JmapAccountDiscovery() }
factory { JmapAccountCreator(get(), get(), get(), 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

@ -1,4 +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,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

@ -7,13 +7,17 @@ import rs.ltt.jmap.client.MethodResponses
import rs.ltt.jmap.common.method.MethodResponse
internal inline fun <reified T : MethodResponse> ListenableFuture<MethodResponses>.getMainResponseBlocking(): T {
return try {
get().getMain(T::class.java)
} catch (e: ExecutionException) {
throw e.cause ?: e
}
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

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