diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 8d7bd0145..0858e3ff9 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -20,11 +20,6 @@ - diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/AccountSetupModule.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/AccountSetupModule.kt index e92f1d53c..1838dc06e 100644 --- a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/AccountSetupModule.kt +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/AccountSetupModule.kt @@ -1,6 +1,7 @@ package app.k9mail.feature.account.setup import app.k9mail.feature.account.setup.ui.AccountSetupViewModel +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigViewModel import app.k9mail.feature.account.setup.ui.options.AccountOptionsContract import app.k9mail.feature.account.setup.ui.options.AccountOptionsValidator import app.k9mail.feature.account.setup.ui.options.AccountOptionsViewModel @@ -16,6 +17,7 @@ val featureAccountSetupModule: Module = module { factory { AccountOptionsValidator() } viewModel { AccountSetupViewModel() } + viewModel { AccountIncomingConfigViewModel() } viewModel { AccountOutgoingConfigViewModel( validator = get(), diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/entity/ConnectionSecurity.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/entity/ConnectionSecurity.kt index 5722ed843..fae553a85 100644 --- a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/entity/ConnectionSecurity.kt +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/entity/ConnectionSecurity.kt @@ -34,3 +34,12 @@ fun ConnectionSecurity.toImapDefaultPort(): Long { TLS -> 993 } } + +@Suppress("MagicNumber") +fun ConnectionSecurity.toPop3DefaultPort(): Long { + return when (this) { + None -> 110 + StartTLS -> 110 + TLS -> 995 + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/entity/IncomingProtocolType.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/entity/IncomingProtocolType.kt new file mode 100644 index 000000000..63fe318a6 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/entity/IncomingProtocolType.kt @@ -0,0 +1,30 @@ +package app.k9mail.feature.account.setup.domain.entity + +import kotlinx.collections.immutable.toImmutableList + +enum class IncomingProtocolType { + IMAP, + POP3, + ; + + companion object { + val DEFAULT = IMAP + + fun all() = values().toList().toImmutableList() + } +} + +@Suppress("SameReturnValue") +fun IncomingProtocolType.toDefaultSecurity(): ConnectionSecurity { + return when (this) { + IncomingProtocolType.IMAP -> ConnectionSecurity.TLS + IncomingProtocolType.POP3 -> ConnectionSecurity.TLS + } +} + +fun IncomingProtocolType.toDefaultPort(connectionSecurity: ConnectionSecurity): Long { + return when (this) { + IncomingProtocolType.IMAP -> connectionSecurity.toImapDefaultPort() + IncomingProtocolType.POP3 -> connectionSecurity.toPop3DefaultPort() + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/input/NumberInputField.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/input/NumberInputField.kt index d6f768f0b..16fd3b468 100644 --- a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/input/NumberInputField.kt +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/input/NumberInputField.kt @@ -3,7 +3,7 @@ package app.k9mail.feature.account.setup.domain.input import app.k9mail.core.common.domain.usecase.validation.ValidationError import app.k9mail.core.common.domain.usecase.validation.ValidationResult -data class NumberInputField( +class NumberInputField( override val value: Long? = null, override val error: ValidationError? = null, override val isValid: Boolean = false, @@ -37,15 +37,35 @@ data class NumberInputField( override fun updateFromValidationResult(result: ValidationResult): NumberInputField { return when (result) { - is ValidationResult.Success -> copy( + is ValidationResult.Success -> NumberInputField( + value = value, error = null, isValid = true, ) - is ValidationResult.Failure -> copy( + is ValidationResult.Failure -> NumberInputField( + value = value, error = result.error, isValid = false, ) } } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as NumberInputField + + if (value != other.value) return false + if (error != other.error) return false + return isValid == other.isValid + } + + override fun hashCode(): Int { + var result = value?.hashCode() ?: 0 + result = 31 * result + (error?.hashCode() ?: 0) + result = 31 * result + isValid.hashCode() + return result + } } diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/input/StringInputField.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/input/StringInputField.kt index 770bf80e2..466863c0e 100644 --- a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/input/StringInputField.kt +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/input/StringInputField.kt @@ -3,7 +3,7 @@ package app.k9mail.feature.account.setup.domain.input import app.k9mail.core.common.domain.usecase.validation.ValidationError import app.k9mail.core.common.domain.usecase.validation.ValidationResult -data class StringInputField( +class StringInputField( override val value: String = "", override val error: ValidationError? = null, override val isValid: Boolean = false, @@ -37,15 +37,35 @@ data class StringInputField( override fun updateFromValidationResult(result: ValidationResult): StringInputField { return when (result) { - is ValidationResult.Success -> copy( + is ValidationResult.Success -> StringInputField( + value = value, error = null, isValid = true, ) - is ValidationResult.Failure -> copy( + is ValidationResult.Failure -> StringInputField( + value = value, error = result.error, isValid = false, ) } } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as StringInputField + + if (value != other.value) return false + if (error != other.error) return false + return isValid == other.isValid + } + + override fun hashCode(): Int { + var result = value.hashCode() + result = 31 * result + (error?.hashCode() ?: 0) + result = 31 * result + isValid.hashCode() + return result + } } diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/AccountSetupScreen.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/AccountSetupScreen.kt index 160b3aaff..ba5a67922 100644 --- a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/AccountSetupScreen.kt +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/AccountSetupScreen.kt @@ -8,7 +8,9 @@ import app.k9mail.feature.account.setup.ui.AccountSetupContract.Event import app.k9mail.feature.account.setup.ui.AccountSetupContract.SetupStep import app.k9mail.feature.account.setup.ui.AccountSetupContract.ViewModel import app.k9mail.feature.account.setup.ui.autoconfig.AccountAutoConfigScreen +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigScreen +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigViewModel import app.k9mail.feature.account.setup.ui.options.AccountOptionsContract import app.k9mail.feature.account.setup.ui.options.AccountOptionsScreen import app.k9mail.feature.account.setup.ui.options.AccountOptionsViewModel @@ -22,6 +24,7 @@ fun AccountSetupScreen( onFinish: () -> Unit, onBack: () -> Unit, viewModel: ViewModel = koinViewModel(), + incomingViewModel: AccountIncomingConfigContract.ViewModel = koinViewModel(), outgoingViewModel: AccountOutgoingConfigContract.ViewModel = koinViewModel(), optionsViewModel: AccountOptionsContract.ViewModel = koinViewModel(), ) { @@ -44,6 +47,7 @@ fun AccountSetupScreen( AccountIncomingConfigScreen( onNext = { dispatch(Event.OnNext) }, onBack = { dispatch(Event.OnBack) }, + viewModel = incomingViewModel, ) } diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/AccountSetupStringMapper.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/AccountSetupStringMapper.kt new file mode 100644 index 000000000..88e286873 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/AccountSetupStringMapper.kt @@ -0,0 +1,13 @@ +package app.k9mail.feature.account.setup.ui + +import android.content.res.Resources +import app.k9mail.feature.account.setup.R +import app.k9mail.feature.account.setup.domain.entity.ConnectionSecurity + +internal fun ConnectionSecurity.toResourceString(resources: Resources): String { + return when (this) { + ConnectionSecurity.None -> resources.getString(R.string.account_setup_connection_security_none) + ConnectionSecurity.StartTLS -> resources.getString(R.string.account_setup_connection_security_start_tls) + ConnectionSecurity.TLS -> resources.getString(R.string.account_setup_connection_security_ssl) + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/incoming/AccountIncomingConfigContent.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/incoming/AccountIncomingConfigContent.kt index c961dfc7e..8983b08a7 100644 --- a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/incoming/AccountIncomingConfigContent.kt +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/incoming/AccountIncomingConfigContent.kt @@ -2,27 +2,48 @@ package app.k9mail.feature.account.setup.ui.incoming import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight 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.atom.text.TextBody1 +import app.k9mail.core.ui.compose.designsystem.molecule.input.CheckboxInput +import app.k9mail.core.ui.compose.designsystem.molecule.input.NumberInput +import app.k9mail.core.ui.compose.designsystem.molecule.input.PasswordInput +import app.k9mail.core.ui.compose.designsystem.molecule.input.SelectInput +import app.k9mail.core.ui.compose.designsystem.molecule.input.TextInput 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.domain.entity.ConnectionSecurity +import app.k9mail.feature.account.setup.domain.entity.IncomingProtocolType +import app.k9mail.feature.account.setup.ui.common.defaultItemPadding +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.State +import app.k9mail.feature.account.setup.ui.toResourceString +import kotlinx.collections.immutable.persistentListOf +@Suppress("LongMethod") @Composable internal fun AccountIncomingConfigContent( + state: State, + onEvent: (Event) -> Unit, contentPadding: PaddingValues, modifier: Modifier = Modifier, ) { + val resources = LocalContext.current.resources + ResponsiveWidthContainer( modifier = Modifier .testTag("AccountIncomingConfigContent") @@ -38,7 +59,110 @@ internal fun AccountIncomingConfigContent( verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.default), ) { item { - TextBody1(text = "Incoming Config") + Spacer(modifier = Modifier.requiredHeight(MainTheme.sizes.smaller)) + } + + item { + SelectInput( + options = IncomingProtocolType.all(), + selectedOption = state.protocolType, + onOptionChange = { onEvent(Event.ProtocolTypeChanged(it)) }, + label = stringResource(id = R.string.account_setup_incoming_config_protocol_type_label), + contentPadding = defaultItemPadding(), + ) + } + + item { + TextInput( + text = state.server.value, + onTextChange = { onEvent(Event.ServerChanged(it)) }, + label = stringResource(id = R.string.account_setup_incoming_config_server_label), + contentPadding = defaultItemPadding(), + ) + } + + item { + SelectInput( + options = ConnectionSecurity.all(), + optionToStringTransformation = { it.toResourceString(resources) }, + selectedOption = state.security, + onOptionChange = { onEvent(Event.SecurityChanged(it)) }, + label = stringResource(id = R.string.account_setup_incoming_config_security_label), + contentPadding = defaultItemPadding(), + ) + } + + item { + NumberInput( + value = state.port.value, + onValueChange = { onEvent(Event.PortChanged(it)) }, + label = stringResource(id = R.string.account_setup_outgoing_config_port_label), + contentPadding = defaultItemPadding(), + ) + } + + item { + TextInput( + text = state.username.value, + onTextChange = { onEvent(Event.UsernameChanged(it)) }, + label = stringResource(id = R.string.account_setup_outgoing_config_username_label), + contentPadding = defaultItemPadding(), + ) + } + + item { + PasswordInput( + password = state.password.value, + onPasswordChange = { onEvent(Event.PasswordChanged(it)) }, + contentPadding = defaultItemPadding(), + ) + } + + item { + // TODO add client certificate support + SelectInput( + options = persistentListOf( + stringResource( + id = R.string.account_setup_client_certificate_none_available, + ), + ), + optionToStringTransformation = { it }, + selectedOption = stringResource( + id = R.string.account_setup_client_certificate_none_available, + ), + onOptionChange = { onEvent(Event.ClientCertificateChanged(it)) }, + label = stringResource(id = R.string.account_setup_outgoing_config_client_certificate_label), + contentPadding = defaultItemPadding(), + ) + } + + if (state.protocolType == IncomingProtocolType.IMAP) { + item { + CheckboxInput( + text = stringResource(id = R.string.account_setup_incoming_config_imap_namespace_label), + checked = state.imapAutodetectNamespaceEnabled, + onCheckedChange = { onEvent(Event.ImapAutoDetectNamespaceChanged(it)) }, + contentPadding = defaultItemPadding(), + ) + } + + item { + TextInput( + text = state.imapPrefix.value, + onTextChange = { onEvent(Event.ImapPrefixChanged(it)) }, + label = stringResource(id = R.string.account_setup_incoming_config_imap_prefix_label), + contentPadding = defaultItemPadding(), + ) + } + } + + item { + CheckboxInput( + text = stringResource(id = R.string.account_setup_incoming_config_compression_label), + checked = state.useCompression, + onCheckedChange = { onEvent(Event.UseCompressionChanged(it)) }, + contentPadding = defaultItemPadding(), + ) } } } @@ -49,6 +173,8 @@ internal fun AccountIncomingConfigContent( internal fun AccountIncomingConfigContentK9Preview() { K9Theme { AccountIncomingConfigContent( + onEvent = { }, + state = State(), contentPadding = PaddingValues(), ) } @@ -59,6 +185,8 @@ internal fun AccountIncomingConfigContentK9Preview() { internal fun AccountIncomingConfigContentThunderbirdPreview() { ThunderbirdTheme { AccountIncomingConfigContent( + onEvent = { }, + state = State(), contentPadding = PaddingValues(), ) } diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/incoming/AccountIncomingConfigContract.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/incoming/AccountIncomingConfigContract.kt new file mode 100644 index 000000000..51441f7d5 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/incoming/AccountIncomingConfigContract.kt @@ -0,0 +1,51 @@ +package app.k9mail.feature.account.setup.ui.incoming + +import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel +import app.k9mail.feature.account.setup.domain.entity.ConnectionSecurity +import app.k9mail.feature.account.setup.domain.entity.IncomingProtocolType +import app.k9mail.feature.account.setup.domain.entity.toDefaultPort +import app.k9mail.feature.account.setup.domain.entity.toDefaultSecurity +import app.k9mail.feature.account.setup.domain.input.NumberInputField +import app.k9mail.feature.account.setup.domain.input.StringInputField + +interface AccountIncomingConfigContract { + + interface ViewModel : UnidirectionalViewModel { + fun initState(state: State) + } + + data class State( + val protocolType: IncomingProtocolType = IncomingProtocolType.DEFAULT, + val server: StringInputField = StringInputField(), + val security: ConnectionSecurity = IncomingProtocolType.DEFAULT.toDefaultSecurity(), + val port: NumberInputField = NumberInputField( + IncomingProtocolType.DEFAULT.toDefaultPort(IncomingProtocolType.DEFAULT.toDefaultSecurity()), + ), + val username: StringInputField = StringInputField(), + val password: StringInputField = StringInputField(), + val clientCertificate: String = "", + val imapAutodetectNamespaceEnabled: Boolean = true, + val imapPrefix: StringInputField = StringInputField(), + val useCompression: Boolean = true, + ) + + sealed class Event { + data class ProtocolTypeChanged(val protocolType: IncomingProtocolType) : Event() + data class ServerChanged(val server: String) : Event() + data class SecurityChanged(val security: ConnectionSecurity) : Event() + data class PortChanged(val port: Long?) : Event() + data class UsernameChanged(val username: String) : Event() + data class PasswordChanged(val password: String) : Event() + data class ClientCertificateChanged(val clientCertificate: String) : Event() + data class ImapAutoDetectNamespaceChanged(val enabled: Boolean) : Event() + data class ImapPrefixChanged(val imapPrefix: String) : Event() + data class UseCompressionChanged(val useCompression: Boolean) : Event() + object OnNextClicked : Event() + object OnBackClicked : Event() + } + + sealed class Effect { + object NavigateNext : Effect() + object NavigateBack : Effect() + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/incoming/AccountIncomingConfigScreen.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/incoming/AccountIncomingConfigScreen.kt index 5a47d9549..de606b0d2 100644 --- a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/incoming/AccountIncomingConfigScreen.kt +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/incoming/AccountIncomingConfigScreen.kt @@ -4,19 +4,31 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource 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 import app.k9mail.core.ui.compose.theme.K9Theme import app.k9mail.core.ui.compose.theme.ThunderbirdTheme import app.k9mail.feature.account.setup.R import app.k9mail.feature.account.setup.ui.common.AccountSetupBottomBar import app.k9mail.feature.account.setup.ui.common.AccountSetupTopAppBar +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Effect +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.ViewModel @Composable fun AccountIncomingConfigScreen( onNext: () -> Unit, onBack: () -> Unit, + viewModel: ViewModel, modifier: Modifier = Modifier, ) { + val (state, dispatch) = viewModel.observe { effect -> + when (effect) { + is Effect.NavigateNext -> onNext() + is Effect.NavigateBack -> onBack() + } + } + Scaffold( topBar = { AccountSetupTopAppBar( @@ -27,13 +39,15 @@ fun AccountIncomingConfigScreen( AccountSetupBottomBar( nextButtonText = stringResource(id = R.string.account_setup_button_next), backButtonText = stringResource(id = R.string.account_setup_button_back), - onNextClick = onNext, - onBackClick = onBack, + onNextClick = { dispatch(Event.OnNextClicked) }, + onBackClick = { dispatch(Event.OnBackClicked) }, ) }, modifier = modifier, ) { innerPadding -> AccountIncomingConfigContent( + onEvent = { dispatch(it) }, + state = state.value, contentPadding = innerPadding, ) } @@ -46,6 +60,7 @@ internal fun AccountIncomingConfigScreenK9Preview() { AccountIncomingConfigScreen( onNext = {}, onBack = {}, + viewModel = AccountIncomingConfigViewModel(), ) } } @@ -57,6 +72,7 @@ internal fun AccountIncomingConfigScreenThunderbirdPreview() { AccountIncomingConfigScreen( onNext = {}, onBack = {}, + viewModel = AccountIncomingConfigViewModel(), ) } } diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/incoming/AccountIncomingConfigViewModel.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/incoming/AccountIncomingConfigViewModel.kt new file mode 100644 index 000000000..f1b8a8174 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/incoming/AccountIncomingConfigViewModel.kt @@ -0,0 +1,80 @@ +package app.k9mail.feature.account.setup.ui.incoming + +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.setup.domain.entity.ConnectionSecurity +import app.k9mail.feature.account.setup.domain.entity.IncomingProtocolType +import app.k9mail.feature.account.setup.domain.entity.toDefaultPort +import app.k9mail.feature.account.setup.domain.entity.toDefaultSecurity +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Effect +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.ClientCertificateChanged +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.ImapAutoDetectNamespaceChanged +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.ImapPrefixChanged +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.OnBackClicked +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.OnNextClicked +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.PasswordChanged +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.PortChanged +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.ProtocolTypeChanged +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.SecurityChanged +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.ServerChanged +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.UseCompressionChanged +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event.UsernameChanged +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.State +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.ViewModel + +class AccountIncomingConfigViewModel( + initialState: State = State(), +) : BaseViewModel(initialState), ViewModel { + + override fun initState(state: State) { + updateState { + state.copy() + } + } + + override fun event(event: Event) { + when (event) { + is ProtocolTypeChanged -> updateProtocolType(event.protocolType) + is ServerChanged -> updateState { it.copy(server = it.server.updateValue(event.server)) } + is SecurityChanged -> updateSecurity(event.security) + is PortChanged -> updateState { it.copy(port = it.port.updateValue(event.port)) } + is UsernameChanged -> updateState { it.copy(username = it.username.updateValue(event.username)) } + is PasswordChanged -> updateState { it.copy(password = it.password.updateValue(event.password)) } + is ClientCertificateChanged -> updateState { it.copy(clientCertificate = event.clientCertificate) } + is ImapAutoDetectNamespaceChanged -> updateState { it.copy(imapAutodetectNamespaceEnabled = event.enabled) } + is ImapPrefixChanged -> updateState { it.copy(imapPrefix = it.imapPrefix.updateValue(event.imapPrefix)) } + is UseCompressionChanged -> updateState { it.copy(useCompression = event.useCompression) } + OnBackClicked -> navigateBack() + OnNextClicked -> submit() + } + } + + private fun updateProtocolType(protocolType: IncomingProtocolType) { + updateState { + it.copy( + protocolType = protocolType, + security = protocolType.toDefaultSecurity(), + port = it.port.updateValue( + protocolType.toDefaultPort(protocolType.toDefaultSecurity()), + ), + ) + } + } + + private fun updateSecurity(security: ConnectionSecurity) { + updateState { + it.copy( + security = security, + port = it.port.updateValue(it.protocolType.toDefaultPort(security)), + ) + } + } + + private fun submit() { + navigateNext() + } + + private fun navigateBack() = emitEffect(Effect.NavigateBack) + + private fun navigateNext() = emitEffect(Effect.NavigateNext) +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/outgoing/AccountOutgoingConfigContent.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/outgoing/AccountOutgoingConfigContent.kt index 90047b622..5bc45ca58 100644 --- a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/outgoing/AccountOutgoingConfigContent.kt +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/outgoing/AccountOutgoingConfigContent.kt @@ -30,6 +30,7 @@ import app.k9mail.feature.account.setup.domain.entity.ConnectionSecurity import app.k9mail.feature.account.setup.ui.common.defaultItemPadding import app.k9mail.feature.account.setup.ui.outgoing.AccountOutgoingConfigContract.Event import app.k9mail.feature.account.setup.ui.outgoing.AccountOutgoingConfigContract.State +import app.k9mail.feature.account.setup.ui.toResourceString import kotlinx.collections.immutable.persistentListOf @Suppress("LongMethod") @@ -119,12 +120,12 @@ internal fun AccountOutgoingConfigContent( SelectInput( options = persistentListOf( stringResource( - id = R.string.account_setup_outgoing_config_client_certificate_none_available, + id = R.string.account_setup_client_certificate_none_available, ), ), optionToStringTransformation = { it }, selectedOption = stringResource( - id = R.string.account_setup_outgoing_config_client_certificate_none_available, + id = R.string.account_setup_client_certificate_none_available, ), onOptionChange = { onEvent(Event.ClientCertificateChanged(it)) }, label = stringResource(id = R.string.account_setup_outgoing_config_client_certificate_label), diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/outgoing/AccountOutgoingConfigViewModel.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/outgoing/AccountOutgoingConfigViewModel.kt index e4ffe39f0..7b57096d8 100644 --- a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/outgoing/AccountOutgoingConfigViewModel.kt +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/outgoing/AccountOutgoingConfigViewModel.kt @@ -33,17 +33,17 @@ class AccountOutgoingConfigViewModel( override fun event(event: Event) { when (event) { - is ServerChanged -> updateState { it.copy(server = it.server.copy(value = event.server)) } + is ServerChanged -> updateState { it.copy(server = it.server.updateValue(event.server)) } is SecurityChanged -> updateSecurity(event.security) - is PortChanged -> updateState { it.copy(port = it.port.copy(value = event.port)) } - is UsernameChanged -> updateState { it.copy(username = it.username.copy(value = event.username)) } - is PasswordChanged -> updateState { it.copy(password = it.password.copy(value = event.password)) } + is PortChanged -> updateState { it.copy(port = it.port.updateValue(event.port)) } + is UsernameChanged -> updateState { it.copy(username = it.username.updateValue(event.username)) } + is PasswordChanged -> updateState { it.copy(password = it.password.updateValue(event.password)) } is ClientCertificateChanged -> updateState { it.copy(clientCertificate = event.clientCertificate) } is ImapAutoDetectNamespaceChanged -> updateState { it.copy(imapAutodetectNamespaceEnabled = event.enabled) } is UseCompressionChanged -> updateState { it.copy(useCompression = event.useCompression) } - OnBackClicked -> navigateBack() OnNextClicked -> submit() + OnBackClicked -> navigateBack() } } @@ -51,10 +51,7 @@ class AccountOutgoingConfigViewModel( updateState { it.copy( security = security, - port = it.port.copy( - value = security.toSmtpDefaultPort(), - error = null, - ), + port = it.port.updateValue(security.toSmtpDefaultPort()), ) } } diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/outgoing/AccountOutgoingStringMapper.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/outgoing/AccountOutgoingStringMapper.kt index 258bfd68b..a72bd0763 100644 --- a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/outgoing/AccountOutgoingStringMapper.kt +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/ui/outgoing/AccountOutgoingStringMapper.kt @@ -3,20 +3,11 @@ package app.k9mail.feature.account.setup.ui.outgoing import android.content.res.Resources import app.k9mail.core.common.domain.usecase.validation.ValidationError import app.k9mail.feature.account.setup.R -import app.k9mail.feature.account.setup.domain.entity.ConnectionSecurity import app.k9mail.feature.account.setup.domain.usecase.ValidatePassword.ValidatePasswordError import app.k9mail.feature.account.setup.domain.usecase.ValidatePort.ValidatePortError import app.k9mail.feature.account.setup.domain.usecase.ValidateServer.ValidateServerError import app.k9mail.feature.account.setup.domain.usecase.ValidateUsername.ValidateUsernameError -internal fun ConnectionSecurity.toResourceString(resources: Resources): String { - return when (this) { - ConnectionSecurity.None -> resources.getString(R.string.account_setup_outgoing_config_security_none) - ConnectionSecurity.StartTLS -> resources.getString(R.string.account_setup_outgoing_config_security_start_tls) - ConnectionSecurity.TLS -> resources.getString(R.string.account_setup_outgoing_config_security_ssl) - } -} - internal fun ValidationError.toResourceString(resources: Resources): String { return when (this) { is ValidateServerError -> toServerErrorString(resources) diff --git a/feature/account/setup/src/main/res/values/strings.xml b/feature/account/setup/src/main/res/values/strings.xml index b91b971b1..aca3f4e43 100644 --- a/feature/account/setup/src/main/res/values/strings.xml +++ b/feature/account/setup/src/main/res/values/strings.xml @@ -4,18 +4,25 @@ Next Back Finish + None + SSL/TLS + StartTLS + None available K-9 Mail Incoming server settings + Protocol + Server + Security + Auto-detect IMAP namespace + IMAP path prefix + Use compression Outgoing server settings Server Server name is required. Security - None - SSL/TLS - StartTLS Port Port is required. Port is invalid (must be 1–65535). @@ -23,7 +30,6 @@ Username is required. Password is required. Client certificate - None available Auto-detect IMAP namespace Use compression diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/entity/ConnectionSecurityTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/entity/ConnectionSecurityTest.kt index 11f4e1424..4cd388642 100644 --- a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/entity/ConnectionSecurityTest.kt +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/entity/ConnectionSecurityTest.kt @@ -1,36 +1,59 @@ package app.k9mail.feature.account.setup.domain.entity +import assertk.assertThat +import assertk.assertions.isEqualTo import org.junit.Test class ConnectionSecurityTest { @Test fun `should provide right default smtp port`() { - val connectionSecurity = ConnectionSecurity.all() + val connectionSecurities = ConnectionSecurity.all() - for (security in connectionSecurity) { + for (security in connectionSecurities) { val port = security.toSmtpDefaultPort() - when (security) { - ConnectionSecurity.None -> assert(port == 587L) - ConnectionSecurity.StartTLS -> assert(port == 587L) - ConnectionSecurity.TLS -> assert(port == 465L) - } + assertThat(port).isEqualTo( + when (security) { + ConnectionSecurity.None -> 587L + ConnectionSecurity.StartTLS -> 587L + ConnectionSecurity.TLS -> 465L + }, + ) } } @Test fun `should provide right default imap port`() { - val connectionSecurity = ConnectionSecurity.all() + val connectionSecurities = ConnectionSecurity.all() - for (security in connectionSecurity) { + for (security in connectionSecurities) { val port = security.toImapDefaultPort() - when (security) { - ConnectionSecurity.None -> assert(port == 143L) - ConnectionSecurity.StartTLS -> assert(port == 143L) - ConnectionSecurity.TLS -> assert(port == 993L) - } + assertThat(port).isEqualTo( + when (security) { + ConnectionSecurity.None -> 143L + ConnectionSecurity.StartTLS -> 143L + ConnectionSecurity.TLS -> 993L + }, + ) + } + } + + @Test + fun `should provide right default pop3 port`() { + val connectionSecurities = ConnectionSecurity.all() + + for (security in connectionSecurities) { + val port = security.toPop3DefaultPort() + + assertThat(port).isEqualTo( + when (security) { + ConnectionSecurity.None -> 110L + ConnectionSecurity.StartTLS -> 110L + ConnectionSecurity.TLS -> 995L + }, + ) } } } diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/entity/IncomingProtocolTypeTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/entity/IncomingProtocolTypeTest.kt new file mode 100644 index 000000000..4931c1953 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/entity/IncomingProtocolTypeTest.kt @@ -0,0 +1,40 @@ +package app.k9mail.feature.account.setup.domain.entity + +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Test + +class IncomingProtocolTypeTest { + + @Test + fun `should provide right default security`() { + val incomingProtocolTypes = IncomingProtocolType.all() + + for (incomingProtocolType in incomingProtocolTypes) { + val security = incomingProtocolType.toDefaultSecurity() + + assertThat(security).isEqualTo( + when (incomingProtocolType) { + IncomingProtocolType.IMAP -> ConnectionSecurity.TLS + IncomingProtocolType.POP3 -> ConnectionSecurity.TLS + }, + ) + } + } + + @Test + fun `should provide right default port`() { + val incomingProtocolTypes = IncomingProtocolType.all() + + for (incomingProtocolType in incomingProtocolTypes) { + val port = incomingProtocolType.toDefaultPort(ConnectionSecurity.TLS) + + assertThat(port).isEqualTo( + when (incomingProtocolType) { + IncomingProtocolType.IMAP -> 993L + IncomingProtocolType.POP3 -> 995L + }, + ) + } + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/input/InputFieldTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/input/InputFieldTest.kt index c5c49b321..4d7c090ff 100644 --- a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/input/InputFieldTest.kt +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/input/InputFieldTest.kt @@ -7,7 +7,9 @@ import assertk.all import assertk.assertThat import assertk.assertions.isEqualTo import assertk.assertions.isFalse +import assertk.assertions.isNotSameAs import assertk.assertions.isNull +import assertk.assertions.isSameAs import assertk.assertions.isTrue import assertk.assertions.prop import org.junit.Test @@ -27,7 +29,7 @@ data class InputFieldTestData( @RunWith(Parameterized::class) class InputFieldTest( - val data: InputFieldTestData, + private val data: InputFieldTestData, ) { @Test @@ -50,6 +52,7 @@ class InputFieldTest( val result = initialInput.updateValue(data.updatedValue) assertThat(result).all { + isNotSameAs(initialInput) hasValue(data.updatedValue) hasNoError() isNotValid() @@ -67,6 +70,7 @@ class InputFieldTest( val result = initialInput.updateError(TestValidationError) assertThat(result).all { + isNotSameAs(initialInput) hasValue(data.initialValue) hasError(TestValidationError) isNotValid() @@ -84,6 +88,7 @@ class InputFieldTest( val result = initialInput.updateValidity(isValid = true) assertThat(result).all { + isNotSameAs(initialInput) hasValue(data.initialValue) hasNoError() isValid() @@ -101,6 +106,7 @@ class InputFieldTest( val result = initialInput.updateValidity(isValid = false) assertThat(result).all { + isSameAs(initialInput) hasValue(data.initialValue) hasError(TestValidationError) isNotValid() @@ -118,6 +124,7 @@ class InputFieldTest( val result = initialInput.updateError(TestValidationError2) assertThat(result).all { + isNotSameAs(initialInput) hasValue(data.initialValue) hasError(TestValidationError2) isNotValid() @@ -135,6 +142,7 @@ class InputFieldTest( val result = initialInput.updateFromValidationResult(ValidationResult.Success) assertThat(result).all { + isNotSameAs(initialInput) hasValue(data.initialValue) hasNoError() isValid() @@ -152,12 +160,45 @@ class InputFieldTest( val result = initialInput.updateFromValidationResult(ValidationResult.Failure(TestValidationError)) assertThat(result).all { + isNotSameAs(initialInput) hasValue(data.initialValue) hasError(TestValidationError) isNotValid() } } + @Test + fun `should decide equality on properties`() { + val input1 = data.createInitialInput( + data.initialValue, + data.initialError, + data.initialIsValid, + ) + val input2 = data.createInitialInput( + data.initialValue, + data.initialError, + data.initialIsValid, + ) + + assertThat(input1.equals(input2)).isTrue() + } + + @Test + fun `should have same hashCode`() { + val input1 = data.createInitialInput( + data.initialValue, + data.initialError, + data.initialIsValid, + ) + val input2 = data.createInitialInput( + data.initialValue, + data.initialError, + data.initialIsValid, + ) + + assertThat(input1.hashCode()).isEqualTo(input2.hashCode()) + } + private fun Assert>.hasValue(value: Any) { prop("value") { InputField<*>::value.call(it) }.isEqualTo(value) } diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/AccountSetupScreenKtTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/AccountSetupScreenKtTest.kt index c67938ed7..ab534e754 100644 --- a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/AccountSetupScreenKtTest.kt +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/AccountSetupScreenKtTest.kt @@ -7,6 +7,7 @@ import app.k9mail.core.ui.compose.theme.ThunderbirdTheme import app.k9mail.feature.account.setup.ui.AccountSetupContract.Effect import app.k9mail.feature.account.setup.ui.AccountSetupContract.SetupStep import app.k9mail.feature.account.setup.ui.AccountSetupContract.State +import app.k9mail.feature.account.setup.ui.incoming.FakeAccountIncomingConfigViewModel import app.k9mail.feature.account.setup.ui.options.FakeAccountOptionsViewModel import app.k9mail.feature.account.setup.ui.outgoing.FakeAccountOutgoingConfigViewModel import assertk.assertThat @@ -21,6 +22,7 @@ class AccountSetupScreenKtTest : ComposeTest() { @Test fun `should display correct screen for every setup step`() = runTest { val viewModel = FakeAccountSetupViewModel() + val incomingViewModel = FakeAccountIncomingConfigViewModel() val outgoingViewModel = FakeAccountOutgoingConfigViewModel() val optionsViewModel = FakeAccountOptionsViewModel() @@ -30,6 +32,7 @@ class AccountSetupScreenKtTest : ComposeTest() { onFinish = { }, onBack = { }, viewModel = viewModel, + incomingViewModel = incomingViewModel, outgoingViewModel = outgoingViewModel, optionsViewModel = optionsViewModel, ) @@ -46,6 +49,7 @@ class AccountSetupScreenKtTest : ComposeTest() { fun `should delegate navigation effects`() = runTest { val initialState = State() val viewModel = FakeAccountSetupViewModel(initialState) + val incomingViewModel = FakeAccountIncomingConfigViewModel() val outgoingViewModel = FakeAccountOutgoingConfigViewModel() val optionsViewModel = FakeAccountOptionsViewModel() var onFinishCounter = 0 @@ -57,6 +61,7 @@ class AccountSetupScreenKtTest : ComposeTest() { onFinish = { onFinishCounter++ }, onBack = { onBackCounter++ }, viewModel = viewModel, + incomingViewModel = incomingViewModel, outgoingViewModel = outgoingViewModel, optionsViewModel = optionsViewModel, ) diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/incoming/AccountIncomingConfigScreenKtTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/incoming/AccountIncomingConfigScreenKtTest.kt new file mode 100644 index 000000000..d00af1869 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/incoming/AccountIncomingConfigScreenKtTest.kt @@ -0,0 +1,44 @@ +package app.k9mail.feature.account.setup.ui.incoming + +import app.k9mail.core.ui.compose.testing.ComposeTest +import app.k9mail.core.ui.compose.testing.setContent +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Effect +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.State +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AccountIncomingConfigScreenKtTest : ComposeTest() { + + @Test + fun `should delegate navigation effects`() = runTest { + val initialState = State() + val viewModel = FakeAccountIncomingConfigViewModel(initialState) + var onNextCounter = 0 + var onBackCounter = 0 + + setContent { + AccountIncomingConfigScreen( + onNext = { onNextCounter++ }, + onBack = { onBackCounter++ }, + viewModel = viewModel, + ) + } + + assertThat(onNextCounter).isEqualTo(0) + assertThat(onBackCounter).isEqualTo(0) + + viewModel.effect(Effect.NavigateNext) + + assertThat(onNextCounter).isEqualTo(1) + assertThat(onBackCounter).isEqualTo(0) + + viewModel.effect(Effect.NavigateBack) + + assertThat(onNextCounter).isEqualTo(1) + assertThat(onBackCounter).isEqualTo(1) + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/incoming/AccountIncomingConfigStateTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/incoming/AccountIncomingConfigStateTest.kt new file mode 100644 index 000000000..666a0e12d --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/incoming/AccountIncomingConfigStateTest.kt @@ -0,0 +1,34 @@ +package app.k9mail.feature.account.setup.ui.incoming + +import app.k9mail.feature.account.setup.domain.entity.ConnectionSecurity +import app.k9mail.feature.account.setup.domain.entity.IncomingProtocolType +import app.k9mail.feature.account.setup.domain.entity.toImapDefaultPort +import app.k9mail.feature.account.setup.domain.input.NumberInputField +import app.k9mail.feature.account.setup.domain.input.StringInputField +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.State +import assertk.all +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isTrue +import assertk.assertions.prop +import org.junit.Test + +class AccountIncomingConfigStateTest { + + @Test + fun `should set default values`() { + val state = State() + + assertThat(state).all { + prop(State::protocolType).isEqualTo(IncomingProtocolType.IMAP) + prop(State::server).isEqualTo(StringInputField()) + prop(State::security).isEqualTo(ConnectionSecurity.DEFAULT) + prop(State::port).isEqualTo(NumberInputField(value = ConnectionSecurity.DEFAULT.toImapDefaultPort())) + prop(State::username).isEqualTo(StringInputField()) + prop(State::password).isEqualTo(StringInputField()) + prop(State::clientCertificate).isEqualTo("") + prop(State::imapAutodetectNamespaceEnabled).isTrue() + prop(State::useCompression).isTrue() + } + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/incoming/AccountIncomingConfigViewModelTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/incoming/AccountIncomingConfigViewModelTest.kt new file mode 100644 index 000000000..b3df7de7b --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/incoming/AccountIncomingConfigViewModelTest.kt @@ -0,0 +1,201 @@ +package app.k9mail.feature.account.setup.ui.incoming + +import androidx.lifecycle.viewmodel.compose.viewModel +import app.cash.turbine.testIn +import app.k9mail.core.ui.compose.testing.MainDispatcherRule +import app.k9mail.feature.account.setup.domain.entity.ConnectionSecurity +import app.k9mail.feature.account.setup.domain.entity.IncomingProtocolType +import app.k9mail.feature.account.setup.domain.entity.toImapDefaultPort +import app.k9mail.feature.account.setup.domain.entity.toPop3DefaultPort +import app.k9mail.feature.account.setup.domain.input.NumberInputField +import app.k9mail.feature.account.setup.domain.input.StringInputField +import app.k9mail.feature.account.setup.testing.eventStateTest +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Effect +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.State +import assertk.assertions.assertThatAndTurbinesConsumed +import assertk.assertions.isEqualTo +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AccountIncomingConfigViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val testSubject = AccountIncomingConfigViewModel() + + @Test + fun `should change protocol, security and port when ProtocolTypeChanged event is received`() = runTest { + val initialState = State( + security = ConnectionSecurity.StartTLS, + port = NumberInputField(value = ConnectionSecurity.StartTLS.toImapDefaultPort()), + ) + testSubject.initState(initialState) + + eventStateTest( + viewModel = testSubject, + initialState = initialState, + event = Event.ProtocolTypeChanged(IncomingProtocolType.POP3), + expectedState = State( + protocolType = IncomingProtocolType.POP3, + security = ConnectionSecurity.TLS, + port = NumberInputField(value = ConnectionSecurity.TLS.toPop3DefaultPort()), + ), + coroutineScope = backgroundScope, + ) + } + + @Test + fun `should change state when ServerChanged event is received`() = runTest { + eventStateTest( + viewModel = testSubject, + initialState = State(), + event = Event.ServerChanged("server"), + expectedState = State(server = StringInputField(value = "server")), + coroutineScope = backgroundScope, + ) + } + + @Test + fun `should change security and port when SecurityChanged event is received`() = runTest { + eventStateTest( + viewModel = testSubject, + initialState = State(), + event = Event.SecurityChanged(ConnectionSecurity.StartTLS), + expectedState = State( + security = ConnectionSecurity.StartTLS, + port = NumberInputField(value = ConnectionSecurity.StartTLS.toImapDefaultPort()), + ), + coroutineScope = backgroundScope, + ) + } + + @Test + fun `should change state when PortChanged event is received`() = runTest { + eventStateTest( + viewModel = testSubject, + initialState = State(), + event = Event.PortChanged(456L), + expectedState = State(port = NumberInputField(value = 456L)), + coroutineScope = backgroundScope, + ) + } + + @Test + fun `should change state when UsernameChanged event is received`() = runTest { + eventStateTest( + viewModel = testSubject, + initialState = State(), + event = Event.UsernameChanged("username"), + expectedState = State(username = StringInputField(value = "username")), + coroutineScope = backgroundScope, + ) + } + + @Test + fun `should change state when PasswordChanged event is received`() = runTest { + eventStateTest( + viewModel = testSubject, + initialState = State(), + event = Event.PasswordChanged("password"), + expectedState = State(password = StringInputField(value = "password")), + coroutineScope = backgroundScope, + ) + } + + @Test + fun `should change state when ClientCertificateChanged event is received`() = runTest { + eventStateTest( + viewModel = testSubject, + initialState = State(), + event = Event.ClientCertificateChanged("clientCertificate"), + expectedState = State(clientCertificate = "clientCertificate"), + coroutineScope = backgroundScope, + ) + } + + @Test + fun `should change state when ImapAutoDetectNamespaceChanged event is received`() = runTest { + eventStateTest( + viewModel = testSubject, + initialState = State(imapAutodetectNamespaceEnabled = true), + event = Event.ImapAutoDetectNamespaceChanged(false), + expectedState = State(imapAutodetectNamespaceEnabled = false), + coroutineScope = backgroundScope, + ) + } + + @Test + fun `should change state when ImapPrefixChanged event is received`() = runTest { + eventStateTest( + viewModel = testSubject, + initialState = State(), + event = Event.ImapPrefixChanged("imapPrefix"), + expectedState = State(imapPrefix = StringInputField(value = "imapPrefix")), + coroutineScope = backgroundScope, + ) + } + + @Test + fun `should change state when UseCompressionChanged event is received`() = runTest { + eventStateTest( + viewModel = testSubject, + initialState = State(useCompression = true), + event = Event.UseCompressionChanged(false), + expectedState = State(useCompression = false), + coroutineScope = backgroundScope, + ) + } + + @Test + fun `should emit NavigateNext effect when OnNextClicked event received`() = runTest { + val viewModel = testSubject + val stateTurbine = viewModel.state.testIn(backgroundScope) + val effectTurbine = viewModel.effect.testIn(backgroundScope) + val turbines = listOf(stateTurbine, effectTurbine) + + assertThatAndTurbinesConsumed( + actual = stateTurbine.awaitItem(), + turbines = turbines, + ) { + isEqualTo(State()) + } + + viewModel.event(Event.OnNextClicked) + + assertThatAndTurbinesConsumed( + actual = effectTurbine.awaitItem(), + turbines = turbines, + ) { + isEqualTo(Effect.NavigateNext) + } + } + + @Test + fun `should emit NavigateBack effect when OnBackClicked event received`() = runTest { + val viewModel = testSubject + val stateTurbine = viewModel.state.testIn(backgroundScope) + val effectTurbine = viewModel.effect.testIn(backgroundScope) + val turbines = listOf(stateTurbine, effectTurbine) + + assertThatAndTurbinesConsumed( + actual = stateTurbine.awaitItem(), + turbines = turbines, + ) { + isEqualTo(State()) + } + + viewModel.event(Event.OnBackClicked) + + assertThatAndTurbinesConsumed( + actual = effectTurbine.awaitItem(), + turbines = turbines, + ) { + isEqualTo(Effect.NavigateBack) + } + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/incoming/FakeAccountIncomingConfigViewModel.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/incoming/FakeAccountIncomingConfigViewModel.kt new file mode 100644 index 000000000..05c35e2d7 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/incoming/FakeAccountIncomingConfigViewModel.kt @@ -0,0 +1,26 @@ +package app.k9mail.feature.account.setup.ui.incoming + +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Effect +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.Event +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.State +import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract.ViewModel + +class FakeAccountIncomingConfigViewModel( + initialState: State = State(), +) : BaseViewModel(initialState), ViewModel { + + val events = mutableListOf() + + override fun initState(state: State) { + updateState { state } + } + + override fun event(event: Event) { + events.add(event) + } + + fun effect(effect: Effect) { + emitEffect(effect) + } +} diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/outgoing/AccountOutgoingConfigScreenKtTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/outgoing/AccountOutgoingConfigScreenKtTest.kt index 0de96e850..0c0d7c18d 100644 --- a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/outgoing/AccountOutgoingConfigScreenKtTest.kt +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/ui/outgoing/AccountOutgoingConfigScreenKtTest.kt @@ -6,9 +6,11 @@ import app.k9mail.feature.account.setup.ui.outgoing.AccountOutgoingConfigContrac import app.k9mail.feature.account.setup.ui.outgoing.AccountOutgoingConfigContract.State import assertk.assertThat import assertk.assertions.isEqualTo +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Test +@OptIn(ExperimentalCoroutinesApi::class) class AccountOutgoingConfigScreenKtTest : ComposeTest() { @Test