Merge pull request #6979 from thundernest/add_account_setup_discovery_loading

Add account setup discovery loading
This commit is contained in:
Wolf-Martell Montwé 2023-06-14 14:41:16 +02:00 committed by GitHub
commit e4574e08d9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 565 additions and 70 deletions

View file

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

View 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&apos;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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +0,0 @@
package app.k9mail.feature.account.setup.domain.entity
data class AutoConfig(
val incomingServerSetting: IncomingServerSetting?,
val outgoingServerSetting: OutgoingServerSetting?,
)

View file

@ -0,0 +1,3 @@
package app.k9mail.feature.account.setup.domain.entity
typealias AutoDiscoveryConnectionSecurity = app.k9mail.autodiscovery.api.ConnectionSecurity

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -18,8 +18,8 @@ class AccountAutoConfigStateTest {
configStep = ConfigStep.EMAIL_ADDRESS,
emailAddress = StringInputField(),
password = StringInputField(),
autoConfig = null,
errorMessage = null,
autoDiscoverySettings = null,
error = null,
isLoading = false,
),
)

View file

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