Merge pull request #6979 from thundernest/add_account_setup_discovery_loading
Add account setup discovery loading
This commit is contained in:
commit
e4574e08d9
28 changed files with 565 additions and 70 deletions
|
@ -10,6 +10,18 @@ android {
|
|||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += listOf(
|
||||
"META-INF/DEPENDENCIES",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
baseline = file("lint-baseline.xml")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
@ -17,4 +29,5 @@ dependencies {
|
|||
|
||||
implementation(projects.feature.onboarding)
|
||||
implementation(projects.feature.account.setup)
|
||||
implementation(libs.okhttp)
|
||||
}
|
||||
|
|
15
app-feature-preview/lint-baseline.xml
Normal file
15
app-feature-preview/lint-baseline.xml
Normal file
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<issues format="6" by="lint 8.0.2" type="baseline" client="gradle" dependencies="true" name="AGP (8.0.2)" variant="all" version="8.0.2">
|
||||
|
||||
<issue
|
||||
id="CustomX509TrustManager"
|
||||
message="Implementing a custom `X509TrustManager` is error-prone and likely to be insecure. It is likely to disable certificate validation altogether, and is non-trivial to implement correctly without calling Android's default implementation."
|
||||
errorLine1=" private class SecureX509TrustManager implements X509TrustManager {"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/com/fsck/k9/mail/ssl/TrustManagerFactory.java"
|
||||
line="70"
|
||||
column="19"/>
|
||||
</issue>
|
||||
|
||||
</issues>
|
|
@ -1,6 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
<application
|
||||
android:name=".FeatureApplication"
|
||||
android:allowBackup="true"
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package app.k9mail.feature.preview
|
||||
|
||||
import android.app.Application
|
||||
import app.k9mail.feature.account.setup.featureAccountSetupModule
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.context.startKoin
|
||||
|
||||
|
@ -12,7 +11,7 @@ class FeatureApplication : Application() {
|
|||
|
||||
startKoin {
|
||||
androidContext(this@FeatureApplication)
|
||||
modules(featureAccountSetupModule)
|
||||
modules(featureModule)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
package app.k9mail.feature.preview
|
||||
|
||||
import app.k9mail.feature.account.setup.featureAccountSetupModule
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.dsl.module
|
||||
|
||||
val featureModule: Module = module {
|
||||
|
||||
// TODO move to network module
|
||||
single<OkHttpClient> {
|
||||
OkHttpClient()
|
||||
}
|
||||
|
||||
includes(featureAccountSetupModule)
|
||||
}
|
|
@ -33,7 +33,7 @@ fun ButtonOutlined(
|
|||
border = BorderStroke(
|
||||
width = 1.dp,
|
||||
color = if (enabled) {
|
||||
MainTheme.colors.primary
|
||||
color ?: MainTheme.colors.primary
|
||||
} else {
|
||||
MainTheme.colors.onSurface.copy(
|
||||
alpha = 0.12f,
|
||||
|
|
|
@ -11,5 +11,7 @@ dependencies {
|
|||
implementation(projects.core.ui.compose.designsystem)
|
||||
implementation(projects.core.common)
|
||||
|
||||
implementation(projects.feature.autodiscovery.service)
|
||||
|
||||
testImplementation(projects.core.ui.compose.testing)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
package app.k9mail.feature.account.setup
|
||||
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryService
|
||||
import app.k9mail.autodiscovery.service.RealAutoDiscoveryService
|
||||
import app.k9mail.feature.account.setup.domain.DomainContract
|
||||
import app.k9mail.feature.account.setup.domain.usecase.GetAutoDiscovery
|
||||
import app.k9mail.feature.account.setup.ui.AccountSetupViewModel
|
||||
import app.k9mail.feature.account.setup.ui.autoconfig.AccountAutoConfigContract
|
||||
import app.k9mail.feature.account.setup.ui.autoconfig.AccountAutoConfigValidator
|
||||
|
@ -18,6 +22,18 @@ import org.koin.core.module.Module
|
|||
import org.koin.dsl.module
|
||||
|
||||
val featureAccountSetupModule: Module = module {
|
||||
single<AutoDiscoveryService> {
|
||||
RealAutoDiscoveryService(
|
||||
okHttpClient = get(),
|
||||
)
|
||||
}
|
||||
|
||||
single<DomainContract.GetAutoDiscoveryUseCase> {
|
||||
GetAutoDiscovery(
|
||||
service = get(),
|
||||
)
|
||||
}
|
||||
|
||||
factory<AccountAutoConfigContract.Validator> { AccountAutoConfigValidator() }
|
||||
factory<AccountIncomingConfigContract.Validator> { AccountIncomingConfigValidator() }
|
||||
factory<AccountOutgoingConfigContract.Validator> { AccountOutgoingConfigValidator() }
|
||||
|
@ -27,6 +43,7 @@ val featureAccountSetupModule: Module = module {
|
|||
viewModel {
|
||||
AccountAutoConfigViewModel(
|
||||
validator = get(),
|
||||
getAutoDiscovery = get(),
|
||||
)
|
||||
}
|
||||
viewModel {
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
package app.k9mail.feature.account.setup.domain
|
||||
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
|
||||
|
||||
interface DomainContract {
|
||||
|
||||
fun interface GetAutoDiscoveryUseCase {
|
||||
suspend fun execute(emailAddress: String): AutoDiscoveryResult
|
||||
}
|
||||
}
|
|
@ -3,14 +3,13 @@ package app.k9mail.feature.account.setup.domain.entity
|
|||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
enum class AuthenticationType {
|
||||
PLAIN,
|
||||
CRAM_MD5,
|
||||
EXTERNAL,
|
||||
OAUTH2,
|
||||
PasswordCleartext,
|
||||
PasswordEncrypted,
|
||||
OAuth2,
|
||||
;
|
||||
|
||||
companion object {
|
||||
val DEFAULT = PLAIN
|
||||
val DEFAULT = PasswordCleartext
|
||||
fun all() = values().toList().toImmutableList()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
package app.k9mail.feature.account.setup.domain.entity
|
||||
|
||||
data class AutoConfig(
|
||||
val incomingServerSetting: IncomingServerSetting?,
|
||||
val outgoingServerSetting: OutgoingServerSetting?,
|
||||
)
|
|
@ -0,0 +1,3 @@
|
|||
package app.k9mail.feature.account.setup.domain.entity
|
||||
|
||||
typealias AutoDiscoveryConnectionSecurity = app.k9mail.autodiscovery.api.ConnectionSecurity
|
|
@ -1,11 +0,0 @@
|
|||
package app.k9mail.feature.account.setup.domain.entity
|
||||
|
||||
data class IncomingServerSetting(
|
||||
val protocol: IncomingProtocolType,
|
||||
val hostname: String,
|
||||
val port: Int,
|
||||
val connectionSecurity: ConnectionSecurity,
|
||||
val authenticationType: AuthenticationType,
|
||||
val username: String?,
|
||||
val isTrusted: Boolean,
|
||||
)
|
|
@ -1,11 +0,0 @@
|
|||
package app.k9mail.feature.account.setup.domain.entity
|
||||
|
||||
data class OutgoingServerSetting(
|
||||
val protocol: OutgoingProtocolType,
|
||||
val hostname: String,
|
||||
val port: Int,
|
||||
val connectionSecurity: ConnectionSecurity,
|
||||
val authenticationType: AuthenticationType,
|
||||
val username: String?,
|
||||
val isTrusted: Boolean,
|
||||
)
|
|
@ -0,0 +1,80 @@
|
|||
package app.k9mail.feature.account.setup.domain.usecase
|
||||
|
||||
import app.k9mail.autodiscovery.api.AuthenticationType
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryService
|
||||
import app.k9mail.autodiscovery.api.ConnectionSecurity
|
||||
import app.k9mail.autodiscovery.api.ImapServerSettings
|
||||
import app.k9mail.autodiscovery.api.SmtpServerSettings
|
||||
import app.k9mail.core.common.mail.toEmailAddress
|
||||
import app.k9mail.core.common.net.toHostname
|
||||
import app.k9mail.core.common.net.toPort
|
||||
import app.k9mail.feature.account.setup.domain.DomainContract
|
||||
import java.io.IOException
|
||||
import kotlin.random.Random
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
internal class GetAutoDiscovery(
|
||||
private val service: AutoDiscoveryService,
|
||||
) : DomainContract.GetAutoDiscoveryUseCase {
|
||||
override suspend fun execute(emailAddress: String): AutoDiscoveryResult {
|
||||
val fakeResult: AutoDiscoveryResult? = if (emailAddress.contains("empty")) {
|
||||
AutoDiscoveryResult.NoUsableSettingsFound
|
||||
} else if (emailAddress.contains("test")) {
|
||||
getFakeAutoDiscovery(emailAddress)
|
||||
} else if (emailAddress.contains("error")) {
|
||||
AutoDiscoveryResult.NetworkError(IOException("Failed to load config"))
|
||||
} else if (emailAddress.contains("unexpected")) {
|
||||
AutoDiscoveryResult.UnexpectedException(Exception("Unexpected exception"))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (fakeResult != null) {
|
||||
return provideWithDelay(fakeResult)
|
||||
}
|
||||
|
||||
return service.discover(emailAddress.toEmailAddress())!!
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private suspend fun provideWithDelay(autoDiscoveryResult: AutoDiscoveryResult): AutoDiscoveryResult {
|
||||
delay(Random(0).nextLong(500, 2000))
|
||||
return autoDiscoveryResult
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private fun getFakeAutoDiscovery(emailAddress: String): AutoDiscoveryResult.Settings {
|
||||
val hasIncomingOauth = emailAddress.contains("in")
|
||||
val hasOutgoingOauth = emailAddress.contains("out")
|
||||
val isTrusted = emailAddress.contains("trust")
|
||||
|
||||
return AutoDiscoveryResult.Settings(
|
||||
incomingServerSettings = ImapServerSettings(
|
||||
hostname = "imap.${getHost(emailAddress)}".toHostname(),
|
||||
port = 993.toPort(),
|
||||
connectionSecurity = ConnectionSecurity.TLS,
|
||||
authenticationType = if (hasIncomingOauth) {
|
||||
AuthenticationType.OAuth2
|
||||
} else {
|
||||
AuthenticationType.PasswordEncrypted
|
||||
},
|
||||
username = "username",
|
||||
),
|
||||
outgoingServerSettings = SmtpServerSettings(
|
||||
hostname = "smtp.${getHost(emailAddress)}".toHostname(),
|
||||
port = 993.toPort(),
|
||||
connectionSecurity = ConnectionSecurity.TLS,
|
||||
authenticationType = if (hasOutgoingOauth) {
|
||||
AuthenticationType.OAuth2
|
||||
} else {
|
||||
AuthenticationType.PasswordEncrypted
|
||||
},
|
||||
username = "username",
|
||||
),
|
||||
isTrusted = isTrusted,
|
||||
)
|
||||
}
|
||||
|
||||
private fun getHost(emailAddress: String) = emailAddress.split("@").last()
|
||||
}
|
|
@ -10,15 +10,20 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.k9mail.core.ui.compose.common.DevicePreviews
|
||||
import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer
|
||||
import app.k9mail.core.ui.compose.theme.K9Theme
|
||||
import app.k9mail.core.ui.compose.theme.MainTheme
|
||||
import app.k9mail.core.ui.compose.theme.ThunderbirdTheme
|
||||
import app.k9mail.feature.account.setup.R
|
||||
import app.k9mail.feature.account.setup.ui.autoconfig.AccountAutoConfigContract.Event
|
||||
import app.k9mail.feature.account.setup.ui.autoconfig.AccountAutoConfigContract.State
|
||||
import app.k9mail.feature.account.setup.ui.autoconfig.item.contentItems
|
||||
import app.k9mail.feature.account.setup.ui.common.item.ErrorItem
|
||||
import app.k9mail.feature.account.setup.ui.common.item.LoadingItem
|
||||
|
||||
@Composable
|
||||
internal fun AccountAutoConfigContent(
|
||||
|
@ -34,16 +39,34 @@ internal fun AccountAutoConfigContent(
|
|||
.fillMaxWidth()
|
||||
.then(modifier),
|
||||
) {
|
||||
val resources = LocalContext.current.resources
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding(),
|
||||
verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.double, Alignment.CenterVertically),
|
||||
) {
|
||||
contentItems(
|
||||
state = state,
|
||||
onEvent = onEvent,
|
||||
)
|
||||
if (state.isLoading) {
|
||||
item(key = "loading") {
|
||||
LoadingItem(
|
||||
message = stringResource(id = R.string.account_setup_auto_config_loading_message),
|
||||
)
|
||||
}
|
||||
} else if (state.error != null) {
|
||||
item(key = "error") {
|
||||
ErrorItem(
|
||||
title = stringResource(id = R.string.account_setup_auto_config_loading_error),
|
||||
message = state.error.toResourceString(resources),
|
||||
onRetry = { onEvent(Event.OnRetryClicked) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
contentItems(
|
||||
state = state,
|
||||
onEvent = onEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
package app.k9mail.feature.account.setup.ui.autoconfig
|
||||
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
|
||||
import app.k9mail.core.common.domain.usecase.validation.ValidationResult
|
||||
import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel
|
||||
import app.k9mail.feature.account.setup.domain.entity.AutoConfig
|
||||
import app.k9mail.feature.account.setup.domain.input.StringInputField
|
||||
|
||||
interface AccountAutoConfigContract {
|
||||
|
@ -21,8 +21,8 @@ interface AccountAutoConfigContract {
|
|||
val configStep: ConfigStep = ConfigStep.EMAIL_ADDRESS,
|
||||
val emailAddress: StringInputField = StringInputField(),
|
||||
val password: StringInputField = StringInputField(),
|
||||
val autoConfig: AutoConfig? = null,
|
||||
val errorMessage: String? = null,
|
||||
val autoDiscoverySettings: AutoDiscoveryResult.Settings? = null,
|
||||
val error: Error? = null,
|
||||
val isLoading: Boolean = false,
|
||||
)
|
||||
|
||||
|
@ -44,4 +44,9 @@ interface AccountAutoConfigContract {
|
|||
fun validateEmailAddress(emailAddress: String): ValidationResult
|
||||
fun validatePassword(password: String): ValidationResult
|
||||
}
|
||||
|
||||
sealed interface Error {
|
||||
object NetworkError : Error
|
||||
object UnknownError : Error
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package app.k9mail.feature.account.setup.ui.autoconfig
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
|
||||
import app.k9mail.core.ui.compose.common.DevicePreviews
|
||||
import app.k9mail.core.ui.compose.common.mvi.observe
|
||||
import app.k9mail.core.ui.compose.designsystem.template.Scaffold
|
||||
|
@ -60,6 +61,7 @@ internal fun AccountAutoConfigScreenK9Preview() {
|
|||
onBack = {},
|
||||
viewModel = AccountAutoConfigViewModel(
|
||||
validator = AccountAutoConfigValidator(),
|
||||
getAutoDiscovery = { AutoDiscoveryResult.NoUsableSettingsFound },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
@ -74,6 +76,7 @@ internal fun AccountAutoConfigScreenThunderbirdPreview() {
|
|||
onBack = {},
|
||||
viewModel = AccountAutoConfigViewModel(
|
||||
validator = AccountAutoConfigValidator(),
|
||||
getAutoDiscovery = { AutoDiscoveryResult.NoUsableSettingsFound },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,19 +1,25 @@
|
|||
package app.k9mail.feature.account.setup.ui.autoconfig
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
|
||||
import app.k9mail.core.common.domain.usecase.validation.ValidationResult
|
||||
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
|
||||
import app.k9mail.feature.account.setup.domain.DomainContract
|
||||
import app.k9mail.feature.account.setup.domain.input.StringInputField
|
||||
import app.k9mail.feature.account.setup.ui.autoconfig.AccountAutoConfigContract.ConfigStep
|
||||
import app.k9mail.feature.account.setup.ui.autoconfig.AccountAutoConfigContract.Effect
|
||||
import app.k9mail.feature.account.setup.ui.autoconfig.AccountAutoConfigContract.Error
|
||||
import app.k9mail.feature.account.setup.ui.autoconfig.AccountAutoConfigContract.Event
|
||||
import app.k9mail.feature.account.setup.ui.autoconfig.AccountAutoConfigContract.State
|
||||
import app.k9mail.feature.account.setup.ui.autoconfig.AccountAutoConfigContract.Validator
|
||||
import app.k9mail.feature.account.setup.ui.autoconfig.AccountAutoConfigContract.ViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Suppress("TooManyFunctions")
|
||||
class AccountAutoConfigViewModel(
|
||||
initialState: State = State(),
|
||||
private val validator: Validator,
|
||||
private val getAutoDiscovery: DomainContract.GetAutoDiscoveryUseCase,
|
||||
) : BaseViewModel<State, Event, Effect>(initialState), ViewModel {
|
||||
|
||||
override fun initState(state: State) {
|
||||
|
@ -50,7 +56,18 @@ class AccountAutoConfigViewModel(
|
|||
|
||||
private fun onNext() {
|
||||
when (state.value.configStep) {
|
||||
ConfigStep.EMAIL_ADDRESS -> submitEmail()
|
||||
ConfigStep.EMAIL_ADDRESS ->
|
||||
if (state.value.error != null) {
|
||||
updateState {
|
||||
it.copy(
|
||||
error = null,
|
||||
configStep = ConfigStep.PASSWORD,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
submitEmail()
|
||||
}
|
||||
|
||||
ConfigStep.PASSWORD -> submitPassword()
|
||||
ConfigStep.OAUTH -> TODO()
|
||||
}
|
||||
|
@ -58,8 +75,9 @@ class AccountAutoConfigViewModel(
|
|||
|
||||
private fun retry() {
|
||||
updateState {
|
||||
it.copy(configStep = ConfigStep.EMAIL_ADDRESS)
|
||||
it.copy(error = null)
|
||||
}
|
||||
loadAutoConfig()
|
||||
}
|
||||
|
||||
private fun submitEmail() {
|
||||
|
@ -69,10 +87,50 @@ class AccountAutoConfigViewModel(
|
|||
|
||||
updateState {
|
||||
it.copy(
|
||||
configStep = if (hasError) ConfigStep.EMAIL_ADDRESS else ConfigStep.PASSWORD,
|
||||
emailAddress = it.emailAddress.updateFromValidationResult(emailValidationResult),
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasError) {
|
||||
loadAutoConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAutoConfig() {
|
||||
viewModelScope.launch {
|
||||
updateState {
|
||||
it.copy(
|
||||
isLoading = true,
|
||||
)
|
||||
}
|
||||
|
||||
val result = getAutoDiscovery.execute(state.value.emailAddress.value)
|
||||
when (result) {
|
||||
AutoDiscoveryResult.NoUsableSettingsFound -> updateAutoDiscoverySettings(null)
|
||||
is AutoDiscoveryResult.Settings -> updateAutoDiscoverySettings(result)
|
||||
is AutoDiscoveryResult.NetworkError -> updateError(Error.NetworkError)
|
||||
is AutoDiscoveryResult.UnexpectedException -> updateError(Error.UnknownError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateAutoDiscoverySettings(settings: AutoDiscoveryResult.Settings?) {
|
||||
updateState {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
autoDiscoverySettings = settings,
|
||||
configStep = ConfigStep.PASSWORD, // TODO use oauth if applicable
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateError(error: Error) {
|
||||
updateState {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -98,7 +156,16 @@ class AccountAutoConfigViewModel(
|
|||
|
||||
private fun onBack() {
|
||||
when (state.value.configStep) {
|
||||
ConfigStep.EMAIL_ADDRESS -> navigateBack()
|
||||
ConfigStep.EMAIL_ADDRESS -> {
|
||||
if (state.value.error != null) {
|
||||
updateState {
|
||||
it.copy(error = null)
|
||||
}
|
||||
} else {
|
||||
navigateBack()
|
||||
}
|
||||
}
|
||||
|
||||
ConfigStep.PASSWORD -> updateState {
|
||||
it.copy(
|
||||
configStep = ConfigStep.EMAIL_ADDRESS,
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
package app.k9mail.feature.account.setup.ui.autoconfig
|
||||
|
||||
import android.content.res.Resources
|
||||
import app.k9mail.feature.account.setup.R
|
||||
|
||||
internal fun AccountAutoConfigContract.Error.toResourceString(resources: Resources): String {
|
||||
return when (this) {
|
||||
AccountAutoConfigContract.Error.NetworkError -> resources.getString(R.string.account_setup_error_network)
|
||||
AccountAutoConfigContract.Error.UnknownError -> resources.getString(R.string.account_setup_error_unknown)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package app.k9mail.feature.account.setup.ui.common.item
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import app.k9mail.core.ui.compose.designsystem.molecule.ErrorView
|
||||
|
||||
@Composable
|
||||
fun LazyItemScope.ErrorItem(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
message: String? = null,
|
||||
onRetry: () -> Unit = { },
|
||||
) {
|
||||
ListItem(
|
||||
modifier = modifier,
|
||||
) {
|
||||
ErrorView(
|
||||
title = title,
|
||||
message = message,
|
||||
onRetry = onRetry,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
package app.k9mail.feature.account.setup.ui.common.item
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import app.k9mail.core.ui.compose.designsystem.molecule.LoadingView
|
||||
|
||||
@Composable
|
||||
fun LazyItemScope.LoadingItem(
|
||||
modifier: Modifier = Modifier,
|
||||
message: String? = null,
|
||||
) {
|
||||
ListItem(
|
||||
modifier = modifier,
|
||||
) {
|
||||
LoadingView(
|
||||
message = message,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -17,8 +17,8 @@ import app.k9mail.feature.account.setup.ui.options.AccountOptionsContract.Valida
|
|||
import app.k9mail.feature.account.setup.ui.options.AccountOptionsContract.ViewModel
|
||||
|
||||
internal class AccountOptionsViewModel(
|
||||
initialState: State = State(),
|
||||
private val validator: Validator,
|
||||
initialState: State = State(),
|
||||
) : BaseViewModel<State, Event, Effect>(initialState), ViewModel {
|
||||
|
||||
override fun initState(state: State) {
|
||||
|
|
|
@ -9,6 +9,9 @@
|
|||
<string name="account_setup_connection_security_start_tls">StartTLS</string>
|
||||
<string name="account_setup_client_certificate_none_available">None available</string>
|
||||
|
||||
<string name="account_setup_error_network">Network </string>
|
||||
<string name="account_setup_error_unknown">Unknown error</string>
|
||||
|
||||
<string name="account_setup_validation_error_email_address_required">Email address is required.</string>
|
||||
<string name="account_setup_validation_error_email_address_invalid">Email address is invalid.</string>
|
||||
<string name="account_setup_validation_error_server_required">Server name is required.</string>
|
||||
|
@ -18,6 +21,9 @@
|
|||
<string name="account_setup_validation_error_password_required">Password is required.</string>
|
||||
<string name="account_setup_validation_error_imap_prefix_blank">Imap prefix can\'t be blank.</string>
|
||||
|
||||
<string name="account_setup_auto_config_loading_message">Finding email details</string>
|
||||
<string name="account_setup_auto_config_loading_error">Failed to load email details</string>
|
||||
|
||||
<string name="account_setup_incoming_config_top_bar_title">Incoming server settings</string>
|
||||
<string name="account_setup_incoming_config_protocol_type_label">Protocol</string>
|
||||
<string name="account_setup_incoming_config_server_label">Server</string>
|
||||
|
|
|
@ -1,20 +1,45 @@
|
|||
package app.k9mail.feature.account.setup
|
||||
|
||||
import app.k9mail.feature.account.setup.ui.AccountSetupContract
|
||||
import app.k9mail.feature.account.setup.ui.autoconfig.AccountAutoConfigContract
|
||||
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract
|
||||
import app.k9mail.feature.account.setup.ui.options.AccountOptionsContract
|
||||
import app.k9mail.feature.account.setup.ui.outgoing.AccountOutgoingConfigContract
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.annotation.KoinExperimentalAPI
|
||||
import org.koin.dsl.koinApplication
|
||||
import org.koin.dsl.module
|
||||
import org.koin.test.KoinTest
|
||||
import org.koin.test.check.checkModules
|
||||
import org.koin.test.verify.verify
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@OptIn(KoinExperimentalAPI::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class AccountSetupModuleKtTest {
|
||||
class AccountSetupModuleKtTest : KoinTest {
|
||||
|
||||
private val networkModule = module {
|
||||
single<OkHttpClient> { OkHttpClient() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should have a valid di module`() {
|
||||
featureAccountSetupModule.verify(
|
||||
extraTypes = listOf(
|
||||
AccountSetupContract.State::class,
|
||||
AccountAutoConfigContract.State::class,
|
||||
AccountIncomingConfigContract.State::class,
|
||||
AccountOutgoingConfigContract.State::class,
|
||||
AccountOptionsContract.State::class,
|
||||
),
|
||||
)
|
||||
|
||||
koinApplication {
|
||||
modules(featureAccountSetupModule)
|
||||
modules(networkModule, featureAccountSetupModule)
|
||||
androidContext(RuntimeEnvironment.getApplication())
|
||||
checkModules()
|
||||
}
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
package app.k9mail.feature.account.setup.domain.entity
|
||||
|
||||
import app.k9mail.autodiscovery.api.AuthenticationType
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
|
||||
import app.k9mail.autodiscovery.api.ConnectionSecurity
|
||||
import app.k9mail.autodiscovery.api.ImapServerSettings
|
||||
import app.k9mail.autodiscovery.api.SmtpServerSettings
|
||||
import app.k9mail.core.common.net.toHostname
|
||||
import app.k9mail.core.common.net.toPort
|
||||
|
||||
object AutoDiscoverySettingsFixture {
|
||||
|
||||
val settings = AutoDiscoveryResult.Settings(
|
||||
incomingServerSettings = ImapServerSettings(
|
||||
hostname = "incoming.example.com".toHostname(),
|
||||
port = 123.toPort(),
|
||||
connectionSecurity = ConnectionSecurity.TLS,
|
||||
authenticationType = AuthenticationType.PasswordEncrypted,
|
||||
username = "incoming_username",
|
||||
),
|
||||
outgoingServerSettings = SmtpServerSettings(
|
||||
hostname = "outgoing.example.com".toHostname(),
|
||||
port = 456.toPort(),
|
||||
connectionSecurity = ConnectionSecurity.TLS,
|
||||
authenticationType = AuthenticationType.PasswordEncrypted,
|
||||
username = "outgoing_username",
|
||||
),
|
||||
isTrusted = true,
|
||||
)
|
||||
}
|
|
@ -18,8 +18,8 @@ class AccountAutoConfigStateTest {
|
|||
configStep = ConfigStep.EMAIL_ADDRESS,
|
||||
emailAddress = StringInputField(),
|
||||
password = StringInputField(),
|
||||
autoConfig = null,
|
||||
errorMessage = null,
|
||||
autoDiscoverySettings = null,
|
||||
error = null,
|
||||
isLoading = false,
|
||||
),
|
||||
)
|
||||
|
|
|
@ -1,24 +1,26 @@
|
|||
package app.k9mail.feature.account.setup.ui.autoconfig
|
||||
|
||||
import app.cash.turbine.testIn
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
|
||||
import app.k9mail.core.common.domain.usecase.validation.ValidationError
|
||||
import app.k9mail.core.common.domain.usecase.validation.ValidationResult
|
||||
import app.k9mail.core.ui.compose.testing.MainDispatcherRule
|
||||
import app.k9mail.feature.account.setup.domain.entity.AutoDiscoverySettingsFixture
|
||||
import app.k9mail.feature.account.setup.domain.input.StringInputField
|
||||
import app.k9mail.feature.account.setup.testing.eventStateTest
|
||||
import app.k9mail.feature.account.setup.ui.autoconfig.AccountAutoConfigContract.ConfigStep
|
||||
import app.k9mail.feature.account.setup.ui.autoconfig.AccountAutoConfigContract.Effect
|
||||
import app.k9mail.feature.account.setup.ui.autoconfig.AccountAutoConfigContract.Error
|
||||
import app.k9mail.feature.account.setup.ui.autoconfig.AccountAutoConfigContract.Event
|
||||
import app.k9mail.feature.account.setup.ui.autoconfig.AccountAutoConfigContract.State
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.assertThatAndTurbinesConsumed
|
||||
import assertk.assertions.isEqualTo
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AccountAutoConfigViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
|
@ -26,6 +28,10 @@ class AccountAutoConfigViewModelTest {
|
|||
|
||||
private val testSubject = AccountAutoConfigViewModel(
|
||||
validator = FakeAccountAutoConfigValidator(),
|
||||
getAutoDiscovery = {
|
||||
delay(50)
|
||||
AutoDiscoveryResult.NoUsableSettingsFound
|
||||
},
|
||||
)
|
||||
|
||||
@Test
|
||||
|
@ -64,28 +70,144 @@ class AccountAutoConfigViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `should change config step to password when OnNextClicked event is received`() = runTest {
|
||||
val initialState = State(
|
||||
configStep = ConfigStep.EMAIL_ADDRESS,
|
||||
emailAddress = StringInputField(value = "email"),
|
||||
)
|
||||
testSubject.initState(initialState)
|
||||
fun `should change state to password when OnNextClicked event is received, input valid and discovery loaded`() =
|
||||
runTest {
|
||||
val autoDiscoverySettings = AutoDiscoverySettingsFixture.settings
|
||||
val initialState = State(
|
||||
configStep = ConfigStep.EMAIL_ADDRESS,
|
||||
emailAddress = StringInputField(value = "email"),
|
||||
)
|
||||
val viewModel = AccountAutoConfigViewModel(
|
||||
validator = FakeAccountAutoConfigValidator(),
|
||||
getAutoDiscovery = {
|
||||
delay(50)
|
||||
autoDiscoverySettings
|
||||
},
|
||||
initialState = initialState,
|
||||
)
|
||||
val stateTurbine = viewModel.state.testIn(backgroundScope)
|
||||
val effectTurbine = viewModel.effect.testIn(backgroundScope)
|
||||
val turbines = listOf(stateTurbine, effectTurbine)
|
||||
|
||||
eventStateTest(
|
||||
viewModel = testSubject,
|
||||
initialState = initialState,
|
||||
event = Event.OnNextClicked,
|
||||
expectedState = State(
|
||||
configStep = ConfigStep.PASSWORD,
|
||||
assertThatAndTurbinesConsumed(
|
||||
actual = stateTurbine.awaitItem(),
|
||||
turbines = turbines,
|
||||
) {
|
||||
isEqualTo(initialState)
|
||||
}
|
||||
|
||||
viewModel.event(Event.OnNextClicked)
|
||||
|
||||
val validatedState = initialState.copy(
|
||||
emailAddress = StringInputField(
|
||||
value = "email",
|
||||
error = null,
|
||||
isValid = true,
|
||||
),
|
||||
),
|
||||
coroutineScope = backgroundScope,
|
||||
)
|
||||
}
|
||||
)
|
||||
assertThat(stateTurbine.awaitItem()).isEqualTo(validatedState)
|
||||
|
||||
val loadingState = validatedState.copy(
|
||||
isLoading = true,
|
||||
)
|
||||
assertThat(stateTurbine.awaitItem()).isEqualTo(loadingState)
|
||||
|
||||
val successState = validatedState.copy(
|
||||
autoDiscoverySettings = autoDiscoverySettings,
|
||||
configStep = ConfigStep.PASSWORD,
|
||||
isLoading = false,
|
||||
)
|
||||
assertThatAndTurbinesConsumed(
|
||||
actual = stateTurbine.awaitItem(),
|
||||
turbines = turbines,
|
||||
) {
|
||||
isEqualTo(successState)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should not change state when OnNextClicked event is received, input valid but discovery failed`() =
|
||||
runTest {
|
||||
val initialState = State(
|
||||
configStep = ConfigStep.EMAIL_ADDRESS,
|
||||
emailAddress = StringInputField(value = "email"),
|
||||
)
|
||||
val discoveryError = Exception("discovery error")
|
||||
val viewModel = AccountAutoConfigViewModel(
|
||||
validator = FakeAccountAutoConfigValidator(),
|
||||
getAutoDiscovery = {
|
||||
delay(50)
|
||||
AutoDiscoveryResult.UnexpectedException(discoveryError)
|
||||
},
|
||||
initialState = initialState,
|
||||
)
|
||||
val stateTurbine = viewModel.state.testIn(backgroundScope)
|
||||
val effectTurbine = viewModel.effect.testIn(backgroundScope)
|
||||
val turbines = listOf(stateTurbine, effectTurbine)
|
||||
|
||||
assertThatAndTurbinesConsumed(
|
||||
actual = stateTurbine.awaitItem(),
|
||||
turbines = turbines,
|
||||
) {
|
||||
isEqualTo(initialState)
|
||||
}
|
||||
|
||||
viewModel.event(Event.OnNextClicked)
|
||||
|
||||
val validatedState = initialState.copy(
|
||||
emailAddress = StringInputField(
|
||||
value = "email",
|
||||
error = null,
|
||||
isValid = true,
|
||||
),
|
||||
)
|
||||
assertThat(stateTurbine.awaitItem()).isEqualTo(validatedState)
|
||||
|
||||
val loadingState = validatedState.copy(
|
||||
isLoading = true,
|
||||
)
|
||||
assertThat(stateTurbine.awaitItem()).isEqualTo(loadingState)
|
||||
|
||||
val failureState = validatedState.copy(
|
||||
isLoading = false,
|
||||
error = AccountAutoConfigContract.Error.UnknownError,
|
||||
)
|
||||
assertThatAndTurbinesConsumed(
|
||||
actual = stateTurbine.awaitItem(),
|
||||
turbines = turbines,
|
||||
) {
|
||||
isEqualTo(failureState)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should reset error state and change to password step when OnNextClicked event received when having error`() =
|
||||
runTest {
|
||||
val initialState = State(
|
||||
configStep = ConfigStep.EMAIL_ADDRESS,
|
||||
emailAddress = StringInputField(
|
||||
value = "email",
|
||||
isValid = true,
|
||||
),
|
||||
error = Error.UnknownError,
|
||||
)
|
||||
testSubject.initState(initialState)
|
||||
|
||||
eventStateTest(
|
||||
viewModel = testSubject,
|
||||
initialState = initialState,
|
||||
event = Event.OnNextClicked,
|
||||
expectedState = State(
|
||||
configStep = ConfigStep.PASSWORD,
|
||||
emailAddress = StringInputField(
|
||||
value = "email",
|
||||
isValid = true,
|
||||
),
|
||||
error = null,
|
||||
),
|
||||
coroutineScope = backgroundScope,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should not change config step to password when OnNextClicked event is received and input invalid`() = runTest {
|
||||
|
@ -97,6 +219,7 @@ class AccountAutoConfigViewModelTest {
|
|||
validator = FakeAccountAutoConfigValidator(
|
||||
emailAddressAnswer = ValidationResult.Failure(TestError),
|
||||
),
|
||||
getAutoDiscovery = { AutoDiscoveryResult.NoUsableSettingsFound },
|
||||
initialState = initialState,
|
||||
)
|
||||
|
||||
|
@ -174,6 +297,7 @@ class AccountAutoConfigViewModelTest {
|
|||
validator = FakeAccountAutoConfigValidator(
|
||||
passwordAnswer = ValidationResult.Failure(TestError),
|
||||
),
|
||||
getAutoDiscovery = { AutoDiscoveryResult.NoUsableSettingsFound },
|
||||
initialState = initialState,
|
||||
)
|
||||
val stateTurbine = viewModel.state.testIn(backgroundScope)
|
||||
|
@ -271,5 +395,34 @@ class AccountAutoConfigViewModelTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should reset error state when OnBackClicked event received when having error and in email address step`() =
|
||||
runTest {
|
||||
val initialState = State(
|
||||
configStep = ConfigStep.EMAIL_ADDRESS,
|
||||
emailAddress = StringInputField(
|
||||
value = "email",
|
||||
isValid = true,
|
||||
),
|
||||
error = Error.UnknownError,
|
||||
)
|
||||
testSubject.initState(initialState)
|
||||
|
||||
eventStateTest(
|
||||
viewModel = testSubject,
|
||||
initialState = initialState,
|
||||
event = Event.OnBackClicked,
|
||||
expectedState = State(
|
||||
configStep = ConfigStep.EMAIL_ADDRESS,
|
||||
emailAddress = StringInputField(
|
||||
value = "email",
|
||||
isValid = true,
|
||||
),
|
||||
error = null,
|
||||
),
|
||||
coroutineScope = backgroundScope,
|
||||
)
|
||||
}
|
||||
|
||||
private object TestError : ValidationError
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue