Merge pull request #7101 from thundernest/oauth_setup_flow

Add OAuth support to automatic setup flow
This commit is contained in:
Wolf-Martell Montwé 2023-07-31 13:53:04 +00:00 committed by GitHub
commit 0d564955d3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 527 additions and 391 deletions

View file

@ -59,6 +59,8 @@ class AccountCreator(
newAccount.outgoingServerSettings = account.outgoingServerSettings
newAccount.oAuthState = account.authorizationState
newAccount.name = account.options.accountName
newAccount.senderName = account.options.displayName
if (account.options.emailSignature != null) {

View file

@ -3,7 +3,10 @@ package com.fsck.k9
import android.view.ContextThemeWrapper
import androidx.lifecycle.LifecycleOwner
import androidx.work.WorkerParameters
import app.k9mail.feature.account.setup.domain.DomainContract.UseCase.ValidateServerSettings
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract
import com.fsck.k9.job.MailSyncWorker
import com.fsck.k9.mail.oauth.AuthStateStorage
import com.fsck.k9.ui.R
import com.fsck.k9.ui.changelog.ChangeLogMode
import com.fsck.k9.ui.changelog.ChangelogViewModel
@ -17,6 +20,7 @@ import org.junit.runner.RunWith
import org.koin.core.annotation.KoinInternalApi
import org.koin.core.logger.PrintLogger
import org.koin.core.parameter.parametersOf
import org.koin.core.qualifier.named
import org.koin.java.KoinJavaComponent
import org.koin.test.AutoCloseKoinTest
import org.koin.test.check.checkModules
@ -34,6 +38,7 @@ class DependencyInjectionTest : AutoCloseKoinTest() {
on { lifecycle } doReturn mock()
}
private val autocryptTransferView = mock<AutocryptKeyTransferActivity>()
private val authStateStorage = mock<AuthStateStorage>()
@KoinInternalApi
@Test
@ -53,6 +58,9 @@ class DependencyInjectionTest : AutoCloseKoinTest() {
withParameters(clazz = Class.forName("com.fsck.k9.view.K9WebViewClient").kotlin) {
parametersOf(null, null)
}
withParameter<AccountValidationContract.ViewModel>(named("incoming_validation")) { authStateStorage }
withParameter<AccountValidationContract.ViewModel>(named("outgoing_validation")) { authStateStorage }
withParameter<ValidateServerSettings> { authStateStorage }
}
}
}

View file

@ -19,6 +19,7 @@ android {
dependencies {
implementation(projects.core.ui.compose.designsystem)
implementation(projects.core.common)
implementation(projects.mail.common)
implementation(projects.feature.account.common)
implementation(libs.appauth)

View file

@ -9,10 +9,10 @@ import app.k9mail.feature.account.oauth.domain.usecase.CheckIsGoogleSignIn
import app.k9mail.feature.account.oauth.domain.usecase.FinishOAuthSignIn
import app.k9mail.feature.account.oauth.domain.usecase.GetOAuthRequestIntent
import app.k9mail.feature.account.oauth.domain.usecase.SuggestServerName
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract
import app.k9mail.feature.account.oauth.ui.AccountOAuthViewModel
import net.openid.appauth.AuthorizationService
import org.koin.android.ext.koin.androidApplication
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.module.Module
import org.koin.dsl.module
@ -48,7 +48,7 @@ val featureAccountOAuthModule: Module = module {
factory<UseCase.CheckIsGoogleSignIn> { CheckIsGoogleSignIn() }
viewModel {
factory<AccountOAuthContract.ViewModel> {
AccountOAuthViewModel(
getOAuthRequestIntent = get(),
finishOAuthSignIn = get(),

View file

@ -1,7 +1,7 @@
package app.k9mail.feature.account.oauth.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -9,55 +9,45 @@ 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.molecule.ErrorView
import app.k9mail.core.ui.compose.designsystem.molecule.LoadingView
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.common.ui.ContentListView
import app.k9mail.feature.account.common.ui.item.ErrorItem
import app.k9mail.feature.account.common.ui.item.LoadingItem
import app.k9mail.feature.account.oauth.R
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Event
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.State
import app.k9mail.feature.account.oauth.ui.item.SignInItem
import app.k9mail.feature.account.oauth.ui.view.SignInView
@Composable
internal fun AccountOAuthContent(
state: State,
onEvent: (Event) -> Unit,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
) {
val resources = LocalContext.current.resources
ContentListView(
Column(
modifier = Modifier
.testTag("AccountOAuthContent")
.then(modifier),
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.double, Alignment.CenterVertically),
) {
if (state.isLoading) {
item(key = "loading") {
LoadingItem(
message = stringResource(id = R.string.account_oauth_loading_message),
)
}
LoadingView(
message = stringResource(id = R.string.account_oauth_loading_message),
)
} else if (state.error != null) {
item(key = "error") {
ErrorItem(
title = stringResource(id = R.string.account_oauth_loading_error),
message = state.error.toResourceString(resources),
onRetry = { onEvent(Event.OnRetryClicked) },
)
}
ErrorView(
title = stringResource(id = R.string.account_oauth_loading_error),
message = state.error.toResourceString(resources),
onRetry = { onEvent(Event.OnRetryClicked) },
)
} else {
item(key = "sign_in") {
SignInItem(
emailAddress = state.emailAddress,
onSignInClick = { onEvent(Event.SignInClicked) },
isGoogleSignIn = state.isGoogleSignIn,
)
}
SignInView(
onSignInClick = { onEvent(Event.SignInClicked) },
isGoogleSignIn = state.isGoogleSignIn,
)
}
}
}
@ -69,7 +59,6 @@ internal fun AccountOAuthContentK9Preview() {
AccountOAuthContent(
state = State(),
onEvent = {},
contentPadding = PaddingValues(),
)
}
}
@ -81,7 +70,6 @@ internal fun AccountOAuthContentThunderbirdPreview() {
AccountOAuthContent(
state = State(),
onEvent = {},
contentPadding = PaddingValues(),
)
}
}

View file

@ -1,23 +1,17 @@
package app.k9mail.feature.account.oauth.ui
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import app.k9mail.core.ui.compose.common.mvi.observe
import app.k9mail.core.ui.compose.designsystem.template.Scaffold
import app.k9mail.feature.account.common.ui.AppTitleTopHeader
import app.k9mail.feature.account.common.ui.WizardNavigationBar
import app.k9mail.feature.account.oauth.R
import app.k9mail.feature.account.oauth.domain.entity.OAuthResult
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Effect
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Event
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.ViewModel
@Composable
fun AccountOAuthScreen(
fun AccountOAuthView(
onOAuthResult: (OAuthResult) -> Unit,
viewModel: ViewModel,
modifier: Modifier = Modifier,
@ -36,29 +30,9 @@ fun AccountOAuthScreen(
}
}
BackHandler {
dispatch(Event.OnBackClicked)
}
Scaffold(
topBar = {
AppTitleTopHeader(stringResource(id = R.string.account_oauth_title))
},
bottomBar = {
WizardNavigationBar(
state = state.value.wizardNavigationBarState,
nextButtonText = stringResource(id = R.string.account_oauth_button_next),
backButtonText = stringResource(id = R.string.account_oauth_button_back),
onNextClick = { },
onBackClick = { dispatch(Event.OnBackClicked) },
)
},
AccountOAuthContent(
state = state.value,
onEvent = { dispatch(it) },
modifier = modifier,
) { innerPadding ->
AccountOAuthContent(
state = state.value,
onEvent = { dispatch(it) },
contentPadding = innerPadding,
)
}
)
}

View file

@ -0,0 +1,14 @@
package app.k9mail.feature.account.oauth.ui
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
// Only used by @DevicePreviews functions
class DummyAccountOAuthViewModel :
BaseViewModel<AccountOAuthContract.State, AccountOAuthContract.Event, AccountOAuthContract.Effect>(
AccountOAuthContract.State(),
),
AccountOAuthContract.ViewModel {
override fun initState(state: AccountOAuthContract.State) = Unit
override fun event(event: AccountOAuthContract.Event) = Unit
}

View file

@ -1,25 +0,0 @@
package app.k9mail.feature.account.oauth.ui.item
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import app.k9mail.feature.account.common.ui.item.ListItem
import app.k9mail.feature.account.oauth.ui.view.SignInView
@Composable
internal fun LazyItemScope.SignInItem(
emailAddress: String,
onSignInClick: () -> Unit,
isGoogleSignIn: Boolean,
modifier: Modifier = Modifier,
) {
ListItem(
modifier = modifier,
) {
SignInView(
emailAddress = emailAddress,
onSignInClick = onSignInClick,
isGoogleSignIn = isGoogleSignIn,
)
}
}

View file

@ -10,14 +10,11 @@ import androidx.compose.ui.text.style.TextAlign
import app.k9mail.core.ui.compose.common.DevicePreviews
import app.k9mail.core.ui.compose.designsystem.atom.button.Button
import app.k9mail.core.ui.compose.designsystem.atom.text.TextCaption
import app.k9mail.core.ui.compose.designsystem.atom.text.TextSubtitle1
import app.k9mail.core.ui.compose.designsystem.molecule.input.EmailAddressInput
import app.k9mail.core.ui.compose.theme.MainTheme
import app.k9mail.feature.account.oauth.R
@Composable
internal fun SignInView(
emailAddress: String,
onSignInClick: () -> Unit,
isGoogleSignIn: Boolean,
modifier: Modifier = Modifier,
@ -27,14 +24,6 @@ internal fun SignInView(
verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.double),
modifier = modifier,
) {
TextSubtitle1(text = stringResource(id = R.string.account_oauth_sign_in_title))
EmailAddressInput(
emailAddress = emailAddress,
onEmailAddressChange = {},
isEnabled = false,
)
TextCaption(
text = stringResource(id = R.string.account_oauth_sign_in_description),
textAlign = TextAlign.Center,
@ -57,7 +46,6 @@ internal fun SignInView(
@Composable
internal fun SignInViewPreview() {
SignInView(
emailAddress = "test@example.com",
onSignInClick = {},
isGoogleSignIn = false,
)
@ -67,7 +55,6 @@ internal fun SignInViewPreview() {
@Composable
internal fun SignInViewWithGooglePreview() {
SignInView(
emailAddress = "test@gmail.com",
onSignInClick = {},
isGoogleSignIn = true,
)

View file

@ -1,95 +0,0 @@
package app.k9mail.feature.account.oauth.ui
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import app.k9mail.core.ui.compose.testing.ComposeTest
import app.k9mail.core.ui.compose.testing.onNodeWithTextIgnoreCase
import app.k9mail.core.ui.compose.testing.setContent
import app.k9mail.core.ui.compose.theme.ThunderbirdTheme
import app.k9mail.feature.account.common.ui.WizardNavigationBarState
import app.k9mail.feature.account.oauth.R
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationState
import app.k9mail.feature.account.oauth.domain.entity.OAuthResult
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Effect
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.State
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
class AccountOAuthScreenKtTest : ComposeTest() {
@Test
fun `should delegate navigation effects`() = runTest {
val initialState = State()
val viewModel = FakeAccountOAuthViewModel(initialState)
var oAuthResult: OAuthResult? = null
val authorizationState = AuthorizationState()
setContent {
ThunderbirdTheme {
AccountOAuthScreen(
onOAuthResult = { oAuthResult = it },
viewModel = viewModel,
)
}
}
assertThat(oAuthResult).isNull()
viewModel.effect(Effect.NavigateNext(authorizationState))
assertThat(oAuthResult).isEqualTo(OAuthResult.Success(authorizationState))
viewModel.effect(Effect.NavigateBack)
assertThat(oAuthResult).isEqualTo(OAuthResult.Failure)
}
@Test
fun `should set navigation bar enabled state`() {
val initialState = State(
wizardNavigationBarState = WizardNavigationBarState(
isNextEnabled = true,
isBackEnabled = true,
),
)
val viewModel = FakeAccountOAuthViewModel(initialState)
setContent {
ThunderbirdTheme {
AccountOAuthScreen(
onOAuthResult = {},
viewModel = viewModel,
)
}
}
onNodeWithTextIgnoreCase(R.string.account_oauth_button_next).assertIsEnabled()
onNodeWithTextIgnoreCase(R.string.account_oauth_button_back).assertIsEnabled()
}
@Test
fun `should set navigation bar disabled state`() {
val initialState = State(
wizardNavigationBarState = WizardNavigationBarState(
isNextEnabled = false,
isBackEnabled = false,
),
)
val viewModel = FakeAccountOAuthViewModel(initialState)
setContent {
ThunderbirdTheme {
AccountOAuthScreen(
onOAuthResult = {},
viewModel = viewModel,
)
}
}
onNodeWithTextIgnoreCase(R.string.account_oauth_button_next).assertIsNotEnabled()
onNodeWithTextIgnoreCase(R.string.account_oauth_button_back).assertIsNotEnabled()
}
}

View file

@ -0,0 +1,44 @@
package app.k9mail.feature.account.oauth.ui
import app.k9mail.core.ui.compose.testing.ComposeTest
import app.k9mail.core.ui.compose.testing.setContent
import app.k9mail.core.ui.compose.theme.ThunderbirdTheme
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationState
import app.k9mail.feature.account.oauth.domain.entity.OAuthResult
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Effect
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.State
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
class AccountOAuthViewKtTest : ComposeTest() {
@Test
fun `should delegate navigation effects`() = runTest {
val initialState = State()
val viewModel = FakeAccountOAuthViewModel(initialState)
var oAuthResult: OAuthResult? = null
val authorizationState = AuthorizationState()
setContent {
ThunderbirdTheme {
AccountOAuthView(
onOAuthResult = { oAuthResult = it },
viewModel = viewModel,
)
}
}
assertThat(oAuthResult).isNull()
viewModel.effect(Effect.NavigateNext(authorizationState))
assertThat(oAuthResult).isEqualTo(OAuthResult.Success(authorizationState))
viewModel.effect(Effect.NavigateBack)
assertThat(oAuthResult).isEqualTo(OAuthResult.Failure)
}
}

View file

@ -3,6 +3,7 @@ package app.k9mail.feature.account.setup
import app.k9mail.autodiscovery.api.AutoDiscoveryService
import app.k9mail.autodiscovery.service.RealAutoDiscoveryService
import app.k9mail.core.common.coreCommonModule
import app.k9mail.feature.account.oauth.featureAccountOAuthModule
import app.k9mail.feature.account.setup.domain.DomainContract
import app.k9mail.feature.account.setup.domain.usecase.CreateAccount
import app.k9mail.feature.account.setup.domain.usecase.GetAutoDiscovery
@ -22,17 +23,20 @@ import app.k9mail.feature.account.setup.ui.outgoing.AccountOutgoingConfigValidat
import app.k9mail.feature.account.setup.ui.outgoing.AccountOutgoingConfigViewModel
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract
import app.k9mail.feature.account.setup.ui.validation.AccountValidationViewModel
import app.k9mail.feature.account.setup.ui.validation.InMemoryAuthStateStorage
import com.fsck.k9.mail.oauth.AuthStateStorage
import com.fsck.k9.mail.store.imap.ImapServerSettingsValidator
import com.fsck.k9.mail.store.pop3.Pop3ServerSettingsValidator
import com.fsck.k9.mail.transport.smtp.SmtpServerSettingsValidator
import okhttp3.OkHttpClient
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.module.Module
import org.koin.core.parameter.parametersOf
import org.koin.core.qualifier.named
import org.koin.dsl.module
val featureAccountSetupModule: Module = module {
includes(coreCommonModule)
includes(coreCommonModule, featureAccountOAuthModule)
single<OkHttpClient> {
OkHttpClient()
@ -51,8 +55,9 @@ val featureAccountSetupModule: Module = module {
)
}
factory<DomainContract.UseCase.ValidateServerSettings> {
factory<DomainContract.UseCase.ValidateServerSettings> { (authStateStorage: AuthStateStorage) ->
ValidateServerSettings(
authStateStorage = authStateStorage,
imapValidator = ImapServerSettingsValidator(
trustedSocketFactory = get(),
oAuth2TokenProviderFactory = get(),
@ -80,43 +85,57 @@ val featureAccountSetupModule: Module = module {
factory<AccountOptionsContract.Validator> { AccountOptionsValidator() }
viewModel {
val authStateStorage = InMemoryAuthStateStorage()
AccountSetupViewModel(
createAccount = get(),
autoDiscoveryViewModel = get(),
incomingViewModel = get(),
incomingValidationViewModel = get(named(NAME_INCOMING_VALIDATION)) { parametersOf(authStateStorage) },
outgoingViewModel = get(),
outgoingValidationViewModel = get(named(NAME_OUTGOING_VALIDATION)) { parametersOf(authStateStorage) },
optionsViewModel = get(),
authStateStorage = authStateStorage,
)
}
viewModel {
factory<AccountAutoDiscoveryContract.ViewModel> {
AccountAutoDiscoveryViewModel(
validator = get(),
getAutoDiscovery = get(),
oAuthViewModel = get(),
)
}
viewModel {
factory<AccountIncomingConfigContract.ViewModel> {
AccountIncomingConfigViewModel(
validator = get(),
)
}
viewModel(named(NAME_INCOMING_VALIDATION)) {
factory<AccountValidationContract.ViewModel>(named(NAME_INCOMING_VALIDATION)) {
(authStateStorage: AuthStateStorage) ->
AccountValidationViewModel(
validateServerSettings = get(),
validateServerSettings = get { parametersOf(authStateStorage) },
initialState = AccountValidationContract.State(
isIncomingValidation = true,
),
)
}
viewModel {
factory<AccountOutgoingConfigContract.ViewModel> {
AccountOutgoingConfigViewModel(
validator = get(),
)
}
viewModel(named(NAME_OUTGOING_VALIDATION)) {
factory<AccountValidationContract.ViewModel>(named(NAME_OUTGOING_VALIDATION)) {
(authStateStorage: AuthStateStorage) ->
AccountValidationViewModel(
validateServerSettings = get(),
validateServerSettings = get { parametersOf(authStateStorage) },
initialState = AccountValidationContract.State(
isIncomingValidation = false,
),
)
}
viewModel {
factory<AccountOptionsContract.ViewModel> {
AccountOptionsViewModel(
validator = get(),
)

View file

@ -22,6 +22,7 @@ interface DomainContract {
emailAddress: String,
incomingServerSettings: ServerSettings,
outgoingServerSettings: ServerSettings,
authorizationState: String?,
options: AccountOptions,
): String
}

View file

@ -6,5 +6,6 @@ data class Account(
val emailAddress: String,
val incomingServerSettings: ServerSettings,
val outgoingServerSettings: ServerSettings,
val authorizationState: String?,
val options: AccountOptions,
)

View file

@ -14,12 +14,14 @@ class CreateAccount(
emailAddress: String,
incomingServerSettings: ServerSettings,
outgoingServerSettings: ServerSettings,
authorizationState: String?,
options: AccountOptions,
): String {
val account = Account(
emailAddress = emailAddress,
incomingServerSettings = incomingServerSettings,
outgoingServerSettings = outgoingServerSettings,
authorizationState = authorizationState,
options = options,
)

View file

@ -2,6 +2,7 @@ package app.k9mail.feature.account.setup.domain.usecase
import app.k9mail.feature.account.setup.domain.DomainContract.UseCase
import com.fsck.k9.mail.ServerSettings
import com.fsck.k9.mail.oauth.AuthStateStorage
import com.fsck.k9.mail.server.ServerSettingsValidationResult
import com.fsck.k9.mail.server.ServerSettingsValidator
import kotlinx.coroutines.CoroutineDispatcher
@ -9,20 +10,22 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal class ValidateServerSettings(
private val authStateStorage: AuthStateStorage,
private val imapValidator: ServerSettingsValidator,
private val pop3Validator: ServerSettingsValidator,
private val smtpValidator: ServerSettingsValidator,
private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : UseCase.ValidateServerSettings {
override suspend fun execute(settings: ServerSettings): ServerSettingsValidationResult =
withContext(coroutineDispatcher) {
return@withContext when (settings.type) {
"imap" -> imapValidator.checkServerSettings(settings, authStateStorage = null)
"pop3" -> pop3Validator.checkServerSettings(settings, authStateStorage = null)
"smtp" -> smtpValidator.checkServerSettings(settings, authStateStorage = null)
override suspend fun execute(settings: ServerSettings): ServerSettingsValidationResult {
return withContext(coroutineDispatcher) {
when (settings.type) {
"imap" -> imapValidator.checkServerSettings(settings, authStateStorage)
"pop3" -> pop3Validator.checkServerSettings(settings, authStateStorage)
"smtp" -> smtpValidator.checkServerSettings(settings, authStateStorage)
else -> {
throw IllegalArgumentException("Unsupported server type: ${settings.type}")
}
}
}
}
}

View file

@ -5,6 +5,7 @@ import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryCon
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 app.k9mail.feature.account.setup.ui.validation.AccountValidationContract
interface AccountSetupContract {
@ -17,7 +18,14 @@ interface AccountSetupContract {
OPTIONS,
}
interface ViewModel : UnidirectionalViewModel<State, Event, Effect>
interface ViewModel : UnidirectionalViewModel<State, Event, Effect> {
val autoDiscoveryViewModel: AccountAutoDiscoveryContract.ViewModel
val incomingViewModel: AccountIncomingConfigContract.ViewModel
val incomingValidationViewModel: AccountValidationContract.ViewModel
val outgoingViewModel: AccountOutgoingConfigContract.ViewModel
val outgoingValidationViewModel: AccountValidationContract.ViewModel
val optionsViewModel: AccountOptionsContract.ViewModel
}
data class State(
val setupStep: SetupStep = SetupStep.AUTO_CONFIG,
@ -32,36 +40,11 @@ interface AccountSetupContract {
val isAutomaticConfig: Boolean,
) : Event
data class OnStateCollected(
val autoDiscoveryState: AccountAutoDiscoveryContract.State,
val incomingState: AccountIncomingConfigContract.State,
val outgoingState: AccountOutgoingConfigContract.State,
val optionsState: AccountOptionsContract.State,
) : Event
object OnBack : Event
}
sealed interface Effect {
data class UpdateIncomingConfig(
val state: AccountIncomingConfigContract.State,
) : Effect
object UpdateIncomingConfigValidation : Effect
data class UpdateOutgoingConfig(
val state: AccountOutgoingConfigContract.State,
) : Effect
object UpdateOutgoingConfigValidation : Effect
data class UpdateOptions(
val state: AccountOptionsContract.State,
) : Effect
object CollectExternalStates : Effect
data class NavigateNext(
val accountUuid: String,
) : Effect

View file

@ -3,75 +3,26 @@ package app.k9mail.feature.account.setup.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import app.k9mail.core.ui.compose.common.mvi.observe
import app.k9mail.feature.account.setup.NAME_INCOMING_VALIDATION
import app.k9mail.feature.account.setup.NAME_OUTGOING_VALIDATION
import app.k9mail.feature.account.setup.ui.AccountSetupContract.Effect
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.autodiscovery.AccountAutoDiscoveryContract
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryScreen
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryViewModel
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.incoming.toValidationState
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
import app.k9mail.feature.account.setup.ui.outgoing.AccountOutgoingConfigContract
import app.k9mail.feature.account.setup.ui.outgoing.AccountOutgoingConfigScreen
import app.k9mail.feature.account.setup.ui.outgoing.AccountOutgoingConfigViewModel
import app.k9mail.feature.account.setup.ui.outgoing.toValidationState
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract
import app.k9mail.feature.account.setup.ui.validation.AccountValidationScreen
import app.k9mail.feature.account.setup.ui.validation.AccountValidationViewModel
import org.koin.androidx.compose.koinViewModel
import org.koin.core.qualifier.named
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Suppress("LongMethod")
@Composable
fun AccountSetupScreen(
onFinish: (String) -> Unit,
onBack: () -> Unit,
viewModel: ViewModel = koinViewModel<AccountSetupViewModel>(),
autoDiscoveryViewModel: AccountAutoDiscoveryContract.ViewModel = koinViewModel<AccountAutoDiscoveryViewModel>(),
incomingViewModel: AccountIncomingConfigContract.ViewModel = koinViewModel<AccountIncomingConfigViewModel>(),
incomingValidationViewModel: AccountValidationContract.ViewModel = koinViewModel<AccountValidationViewModel>(
named(
NAME_INCOMING_VALIDATION,
),
),
outgoingViewModel: AccountOutgoingConfigContract.ViewModel = koinViewModel<AccountOutgoingConfigViewModel>(),
outgoingValidationViewModel: AccountValidationContract.ViewModel = koinViewModel<AccountValidationViewModel>(
named(
NAME_OUTGOING_VALIDATION,
),
),
optionsViewModel: AccountOptionsContract.ViewModel = koinViewModel<AccountOptionsViewModel>(),
) {
val (state, dispatch) = viewModel.observe { effect ->
when (effect) {
is Effect.UpdateIncomingConfig -> incomingViewModel.initState(effect.state)
is Effect.UpdateIncomingConfigValidation -> {
incomingValidationViewModel.initState(incomingViewModel.state.value.toValidationState())
}
is Effect.UpdateOutgoingConfig -> outgoingViewModel.initState(effect.state)
is Effect.UpdateOutgoingConfigValidation -> {
outgoingValidationViewModel.initState(outgoingViewModel.state.value.toValidationState())
}
is Effect.UpdateOptions -> optionsViewModel.initState(effect.state)
is Effect.CollectExternalStates -> viewModel.event(
Event.OnStateCollected(
autoDiscoveryState = autoDiscoveryViewModel.state.value,
incomingState = incomingViewModel.state.value,
outgoingState = outgoingViewModel.state.value,
optionsState = optionsViewModel.state.value,
),
)
is Effect.NavigateNext -> onFinish(effect.accountUuid)
Effect.NavigateBack -> onBack()
}
@ -89,7 +40,7 @@ fun AccountSetupScreen(
)
},
onBack = { dispatch(Event.OnBack) },
viewModel = autoDiscoveryViewModel,
viewModel = viewModel.autoDiscoveryViewModel,
)
}
@ -97,7 +48,7 @@ fun AccountSetupScreen(
AccountIncomingConfigScreen(
onNext = { dispatch(Event.OnNext) },
onBack = { dispatch(Event.OnBack) },
viewModel = incomingViewModel,
viewModel = viewModel.incomingViewModel,
)
}
@ -105,7 +56,7 @@ fun AccountSetupScreen(
AccountValidationScreen(
onNext = { dispatch(Event.OnNext) },
onBack = { dispatch(Event.OnBack) },
viewModel = incomingValidationViewModel,
viewModel = viewModel.incomingValidationViewModel,
)
}
@ -113,7 +64,7 @@ fun AccountSetupScreen(
AccountOutgoingConfigScreen(
onNext = { dispatch(Event.OnNext) },
onBack = { dispatch(Event.OnBack) },
viewModel = outgoingViewModel,
viewModel = viewModel.outgoingViewModel,
)
}
@ -121,7 +72,7 @@ fun AccountSetupScreen(
AccountValidationScreen(
onNext = { dispatch(Event.OnNext) },
onBack = { dispatch(Event.OnBack) },
viewModel = outgoingValidationViewModel,
viewModel = viewModel.outgoingValidationViewModel,
)
}
@ -129,7 +80,7 @@ fun AccountSetupScreen(
AccountOptionsScreen(
onNext = { dispatch(Event.OnNext) },
onBack = { dispatch(Event.OnBack) },
viewModel = optionsViewModel,
viewModel = viewModel.optionsViewModel,
)
}
}

View file

@ -7,23 +7,34 @@ import app.k9mail.feature.account.setup.ui.AccountSetupContract.Effect
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.State
import app.k9mail.feature.account.setup.ui.AccountSetupContract.ViewModel
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract
import app.k9mail.feature.account.setup.ui.common.mapper.toIncomingConfigState
import app.k9mail.feature.account.setup.ui.common.mapper.toOptionsState
import app.k9mail.feature.account.setup.ui.common.mapper.toOutgoingConfigState
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract
import app.k9mail.feature.account.setup.ui.incoming.toServerSettings
import app.k9mail.feature.account.setup.ui.incoming.toValidationState
import app.k9mail.feature.account.setup.ui.options.AccountOptionsContract
import app.k9mail.feature.account.setup.ui.options.toAccountOptions
import app.k9mail.feature.account.setup.ui.outgoing.AccountOutgoingConfigContract
import app.k9mail.feature.account.setup.ui.outgoing.toServerSettings
import app.k9mail.feature.account.setup.ui.outgoing.toValidationState
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract
import com.fsck.k9.mail.oauth.AuthStateStorage
import kotlinx.coroutines.launch
@Suppress("LongParameterList")
class AccountSetupViewModel(
private val createAccount: UseCase.CreateAccount,
override val autoDiscoveryViewModel: AccountAutoDiscoveryContract.ViewModel,
override val incomingViewModel: AccountIncomingConfigContract.ViewModel,
override val incomingValidationViewModel: AccountValidationContract.ViewModel,
override val outgoingViewModel: AccountOutgoingConfigContract.ViewModel,
override val outgoingValidationViewModel: AccountValidationContract.ViewModel,
override val optionsViewModel: AccountOptionsContract.ViewModel,
private val authStateStorage: AuthStateStorage,
initialState: State = State(),
) : BaseViewModel<State, Event, Effect>(initialState), ViewModel {
) : BaseViewModel<State, Event, Effect>(initialState), AccountSetupContract.ViewModel {
override fun event(event: Event) {
when (event) {
@ -36,13 +47,6 @@ class AccountSetupViewModel(
onAutoDiscoveryFinished(event.state)
}
is Event.OnStateCollected -> onStateCollected(
autoDiscoveryState = event.autoDiscoveryState,
incomingState = event.incomingState,
outgoingState = event.outgoingState,
optionsState = event.optionsState,
)
Event.OnBack -> onBack()
Event.OnNext -> onNext()
}
@ -51,9 +55,10 @@ class AccountSetupViewModel(
private fun onAutoDiscoveryFinished(
autoDiscoveryState: AccountAutoDiscoveryContract.State,
) {
emitEffect(Effect.UpdateIncomingConfig(autoDiscoveryState.toIncomingConfigState()))
emitEffect(Effect.UpdateOutgoingConfig(autoDiscoveryState.toOutgoingConfigState()))
emitEffect(Effect.UpdateOptions(autoDiscoveryState.toOptionsState()))
authStateStorage.updateAuthorizationState(autoDiscoveryState.authorizationState?.state)
incomingViewModel.initState(autoDiscoveryState.toIncomingConfigState())
outgoingViewModel.initState(autoDiscoveryState.toOutgoingConfigState())
optionsViewModel.initState(autoDiscoveryState.toOptionsState())
onNext()
}
@ -86,8 +91,8 @@ class AccountSetupViewModel(
when (state.value.setupStep) {
SetupStep.AUTO_CONFIG -> {
if (state.value.isAutomaticConfig) {
emitEffect(Effect.UpdateIncomingConfigValidation)
emitEffect(Effect.UpdateOutgoingConfigValidation)
incomingValidationViewModel.initState(incomingViewModel.state.value.toValidationState())
outgoingValidationViewModel.initState(outgoingViewModel.state.value.toValidationState())
changeToSetupStep(SetupStep.INCOMING_VALIDATION)
} else {
changeToSetupStep(SetupStep.INCOMING_CONFIG)
@ -95,7 +100,7 @@ class AccountSetupViewModel(
}
SetupStep.INCOMING_CONFIG -> {
emitEffect(Effect.UpdateIncomingConfigValidation)
incomingValidationViewModel.initState(incomingViewModel.state.value.toValidationState())
changeToSetupStep(SetupStep.INCOMING_VALIDATION)
}
@ -108,7 +113,7 @@ class AccountSetupViewModel(
}
SetupStep.OUTGOING_CONFIG -> {
emitEffect(Effect.UpdateOutgoingConfigValidation)
outgoingValidationViewModel.initState(outgoingViewModel.state.value.toValidationState())
changeToSetupStep(SetupStep.OUTGOING_VALIDATION)
}
@ -121,6 +126,10 @@ class AccountSetupViewModel(
}
private fun changeToSetupStep(setupStep: SetupStep) {
if (setupStep == SetupStep.AUTO_CONFIG) {
authStateStorage.updateAuthorizationState(authorizationState = null)
}
updateState {
it.copy(
setupStep = setupStep,
@ -129,20 +138,17 @@ class AccountSetupViewModel(
}
private fun onFinish() {
emitEffect(Effect.CollectExternalStates)
}
val autoDiscoveryState = autoDiscoveryViewModel.state.value
val incomingState = incomingViewModel.state.value
val outgoingState = outgoingViewModel.state.value
val optionsState = optionsViewModel.state.value
private fun onStateCollected(
autoDiscoveryState: AccountAutoDiscoveryContract.State,
incomingState: AccountIncomingConfigContract.State,
outgoingState: AccountOutgoingConfigContract.State,
optionsState: AccountOptionsContract.State,
) {
viewModelScope.launch {
val result = createAccount.execute(
emailAddress = autoDiscoveryState.emailAddress.value,
incomingServerSettings = incomingState.toServerSettings(),
outgoingServerSettings = outgoingState.toServerSettings(),
authorizationState = authStateStorage.getAuthorizationState(),
options = optionsState.toAccountOptions(),
)

View file

@ -20,6 +20,8 @@ import app.k9mail.core.ui.compose.theme.MainTheme
import app.k9mail.core.ui.compose.theme.ThunderbirdTheme
import app.k9mail.feature.account.common.ui.item.ErrorItem
import app.k9mail.feature.account.common.ui.item.LoadingItem
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract
import app.k9mail.feature.account.oauth.ui.DummyAccountOAuthViewModel
import app.k9mail.feature.account.setup.R
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.Event
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.State
@ -29,6 +31,7 @@ import app.k9mail.feature.account.setup.ui.autodiscovery.item.contentItems
internal fun AccountAutoDiscoveryContent(
state: State,
onEvent: (Event) -> Unit,
oAuthViewModel: AccountOAuthContract.ViewModel,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
) {
@ -65,6 +68,7 @@ internal fun AccountAutoDiscoveryContent(
contentItems(
state = state,
onEvent = onEvent,
oAuthViewModel = oAuthViewModel,
)
}
}
@ -79,6 +83,7 @@ internal fun AccountAutoDiscoveryContentK9Preview() {
state = State(),
onEvent = {},
contentPadding = PaddingValues(),
oAuthViewModel = DummyAccountOAuthViewModel(),
)
}
}
@ -91,6 +96,7 @@ internal fun AccountAutoDiscoveryContentThunderbirdPreview() {
state = State(),
onEvent = {},
contentPadding = PaddingValues(),
oAuthViewModel = DummyAccountOAuthViewModel(),
)
}
}

View file

@ -3,6 +3,9 @@ package app.k9mail.feature.account.setup.ui.autodiscovery
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.oauth.domain.entity.AuthorizationState
import app.k9mail.feature.account.oauth.domain.entity.OAuthResult
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract
import app.k9mail.feature.account.setup.domain.input.BooleanInputField
import app.k9mail.feature.account.setup.domain.input.StringInputField
@ -15,6 +18,8 @@ interface AccountAutoDiscoveryContract {
}
interface ViewModel : UnidirectionalViewModel<State, Event, Effect> {
val oAuthViewModel: AccountOAuthContract.ViewModel
fun initState(state: State)
}
@ -24,6 +29,9 @@ interface AccountAutoDiscoveryContract {
val password: StringInputField = StringInputField(),
val autoDiscoverySettings: AutoDiscoveryResult.Settings? = null,
val configurationApproved: BooleanInputField = BooleanInputField(),
val authorizationState: AuthorizationState? = null,
val isSuccess: Boolean = false,
val error: Error? = null,
val isLoading: Boolean = false,
)
@ -32,6 +40,7 @@ interface AccountAutoDiscoveryContract {
data class EmailAddressChanged(val emailAddress: String) : Event()
data class PasswordChanged(val password: String) : Event()
data class ConfigurationApprovalChanged(val confirmed: Boolean) : Event()
data class OnOAuthResult(val result: OAuthResult) : Event()
object OnNextClicked : Event()
object OnBackClicked : Event()
@ -40,9 +49,8 @@ interface AccountAutoDiscoveryContract {
}
sealed class Effect {
data class NavigateNext(
val isAutomaticConfig: Boolean,
) : Effect()
data class NavigateNext(val isAutomaticConfig: Boolean) : Effect()
object NavigateBack : Effect()
}

View file

@ -12,6 +12,7 @@ import app.k9mail.core.ui.compose.theme.K9Theme
import app.k9mail.core.ui.compose.theme.ThunderbirdTheme
import app.k9mail.feature.account.common.ui.AppTitleTopHeader
import app.k9mail.feature.account.common.ui.WizardNavigationBar
import app.k9mail.feature.account.oauth.ui.DummyAccountOAuthViewModel
import app.k9mail.feature.account.setup.R
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.Effect
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.Event
@ -55,6 +56,7 @@ internal fun AccountAutoDiscoveryScreen(
AccountAutoDiscoveryContent(
state = state.value,
onEvent = { dispatch(it) },
oAuthViewModel = viewModel.oAuthViewModel,
contentPadding = innerPadding,
)
}
@ -70,6 +72,7 @@ internal fun AccountAutoDiscoveryScreenK9Preview() {
viewModel = AccountAutoDiscoveryViewModel(
validator = AccountAutoDiscoveryValidator(),
getAutoDiscovery = { AutoDiscoveryResult.NoUsableSettingsFound },
oAuthViewModel = DummyAccountOAuthViewModel(),
),
)
}
@ -85,6 +88,7 @@ internal fun AccountAutoDiscoveryScreenThunderbirdPreview() {
viewModel = AccountAutoDiscoveryViewModel(
validator = AccountAutoDiscoveryValidator(),
getAutoDiscovery = { AutoDiscoveryResult.NoUsableSettingsFound },
oAuthViewModel = DummyAccountOAuthViewModel(),
),
)
}

View file

@ -2,9 +2,13 @@ package app.k9mail.feature.account.setup.ui.autodiscovery
import androidx.lifecycle.viewModelScope
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
import app.k9mail.autodiscovery.api.ImapServerSettings
import app.k9mail.core.common.domain.usecase.validation.ValidationResult
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
import app.k9mail.feature.account.oauth.domain.entity.OAuthResult
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract
import app.k9mail.feature.account.setup.domain.DomainContract.UseCase
import app.k9mail.feature.account.setup.domain.entity.AutoDiscoveryAuthenticationType
import app.k9mail.feature.account.setup.domain.input.StringInputField
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.ConfigStep
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.Effect
@ -12,7 +16,6 @@ import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryCon
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.Event
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.State
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.Validator
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.ViewModel
import kotlinx.coroutines.launch
@Suppress("TooManyFunctions")
@ -20,7 +23,8 @@ internal class AccountAutoDiscoveryViewModel(
initialState: State = State(),
private val validator: Validator,
private val getAutoDiscovery: UseCase.GetAutoDiscovery,
) : BaseViewModel<State, Event, Effect>(initialState), ViewModel {
override val oAuthViewModel: AccountOAuthContract.ViewModel,
) : BaseViewModel<State, Event, Effect>(initialState), AccountAutoDiscoveryContract.ViewModel {
override fun initState(state: State) {
updateState {
@ -33,6 +37,7 @@ internal class AccountAutoDiscoveryViewModel(
is Event.EmailAddressChanged -> changeEmailAddress(event.emailAddress)
is Event.PasswordChanged -> changePassword(event.password)
is Event.ConfigurationApprovalChanged -> changeConfigurationApproval(event.confirmed)
is Event.OnOAuthResult -> onOAuthResult(event.result)
Event.OnNextClicked -> onNext()
Event.OnBackClicked -> onBack()
@ -120,7 +125,7 @@ internal class AccountAutoDiscoveryViewModel(
val result = getAutoDiscovery.execute(state.value.emailAddress.value)
when (result) {
AutoDiscoveryResult.NoUsableSettingsFound -> updateAutoDiscoverySettings(null)
AutoDiscoveryResult.NoUsableSettingsFound -> updateNoSettingsFound()
is AutoDiscoveryResult.Settings -> updateAutoDiscoverySettings(result)
is AutoDiscoveryResult.NetworkError -> updateError(Error.NetworkError)
is AutoDiscoveryResult.UnexpectedException -> updateError(Error.UnknownError)
@ -128,12 +133,36 @@ internal class AccountAutoDiscoveryViewModel(
}
}
private fun updateAutoDiscoverySettings(settings: AutoDiscoveryResult.Settings?) {
private fun updateNoSettingsFound() {
updateState {
it.copy(
isLoading = false,
autoDiscoverySettings = null,
configStep = ConfigStep.PASSWORD,
)
}
}
private fun updateAutoDiscoverySettings(settings: AutoDiscoveryResult.Settings) {
val imapServerSettings = settings.incomingServerSettings as ImapServerSettings
val isOAuth = imapServerSettings.authenticationTypes.first() == AutoDiscoveryAuthenticationType.OAuth2
if (isOAuth) {
oAuthViewModel.initState(
AccountOAuthContract.State(
hostname = imapServerSettings.hostname.value,
emailAddress = state.value.emailAddress.value,
),
)
}
// TODO: disable next button if isOAuth = true
updateState {
it.copy(
isLoading = false,
autoDiscoverySettings = settings,
configStep = ConfigStep.PASSWORD, // TODO use oauth if applicable
configStep = if (isOAuth) ConfigStep.OAUTH else ConfigStep.PASSWORD,
)
}
}
@ -189,14 +218,28 @@ internal class AccountAutoDiscoveryViewModel(
}
}
ConfigStep.PASSWORD -> updateState {
ConfigStep.OAUTH,
ConfigStep.PASSWORD,
-> updateState {
it.copy(
configStep = ConfigStep.EMAIL_ADDRESS,
password = StringInputField(),
)
}
}
}
ConfigStep.OAUTH -> TODO()
private fun onOAuthResult(result: OAuthResult) {
if (result is OAuthResult.Success) {
updateState {
it.copy(authorizationState = result.authorizationState)
}
navigateNext(isAutomaticConfig = true)
} else {
updateState {
it.copy(authorizationState = null)
}
}
}

View file

@ -1,6 +1,9 @@
package app.k9mail.feature.account.setup.ui.autodiscovery.item
import androidx.compose.foundation.lazy.LazyListScope
import app.k9mail.feature.account.common.ui.item.ListItem
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract
import app.k9mail.feature.account.oauth.ui.AccountOAuthView
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.ConfigStep
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.Event
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.State
@ -8,8 +11,9 @@ import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryCon
internal fun LazyListScope.contentItems(
state: State,
onEvent: (Event) -> Unit,
oAuthViewModel: AccountOAuthContract.ViewModel,
) {
if (state.configStep == ConfigStep.PASSWORD) {
if (state.configStep != ConfigStep.EMAIL_ADDRESS) {
item(key = "autodiscovery") {
AutoDiscoveryStatusItem(
autoDiscoverySettings = state.autoDiscoverySettings,
@ -43,5 +47,14 @@ internal fun LazyListScope.contentItems(
onPasswordChange = { onEvent(Event.PasswordChanged(it)) },
)
}
} else if (state.configStep == ConfigStep.OAUTH) {
item(key = "oauth") {
ListItem {
AccountOAuthView(
onOAuthResult = { result -> onEvent(Event.OnOAuthResult(result)) },
viewModel = oAuthViewModel,
)
}
}
}
}

View file

@ -7,7 +7,6 @@ import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.Error
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.Event
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.State
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract.ViewModel
import com.fsck.k9.mail.server.ServerSettingsValidationResult
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -17,7 +16,7 @@ private const val CONTINUE_NEXT_DELAY = 2000L
internal class AccountValidationViewModel(
initialState: State = State(),
private val validateServerSettings: DomainContract.UseCase.ValidateServerSettings,
) : BaseViewModel<State, Event, Effect>(initialState), ViewModel {
) : BaseViewModel<State, Event, Effect>(initialState), AccountValidationContract.ViewModel {
override fun initState(state: State) {
updateState {

View file

@ -0,0 +1,17 @@
package app.k9mail.feature.account.setup.ui.validation
import com.fsck.k9.mail.oauth.AuthStateStorage
class InMemoryAuthStateStorage : AuthStateStorage {
private var authorizationState: String? = null
@Synchronized
override fun getAuthorizationState(): String? {
return authorizationState
}
@Synchronized
override fun updateAuthorizationState(authorizationState: String?) {
this.authorizationState = authorizationState
}
}

View file

@ -1,14 +1,19 @@
package app.k9mail.feature.account.setup
import android.content.Context
import app.k9mail.core.common.oauth.OAuthConfigurationFactory
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract
import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCreator
import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCreator.AccountCreatorResult
import app.k9mail.feature.account.setup.domain.DomainContract.UseCase.ValidateServerSettings
import app.k9mail.feature.account.setup.ui.AccountSetupContract
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract
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 app.k9mail.feature.account.setup.ui.validation.AccountValidationContract
import app.k9mail.feature.account.setup.ui.validation.InMemoryAuthStateStorage
import com.fsck.k9.mail.oauth.AuthStateStorage
import com.fsck.k9.mail.oauth.OAuth2TokenProvider
import com.fsck.k9.mail.oauth.OAuth2TokenProviderFactory
import com.fsck.k9.mail.ssl.TrustedSocketFactory
@ -18,6 +23,7 @@ import org.junit.runner.RunWith
import org.koin.android.ext.koin.androidContext
import org.koin.core.annotation.KoinExperimentalAPI
import org.koin.core.module.Module
import org.koin.core.qualifier.named
import org.koin.dsl.koinApplication
import org.koin.dsl.module
import org.koin.test.KoinTest
@ -55,17 +61,26 @@ class AccountSetupModuleKtTest : KoinTest {
extraTypes = listOf(
AccountSetupContract.State::class,
AccountAutoDiscoveryContract.State::class,
AccountOAuthContract.State::class,
AccountValidationContract.State::class,
AccountIncomingConfigContract.State::class,
AccountOutgoingConfigContract.State::class,
AccountOptionsContract.State::class,
AuthStateStorage::class,
Context::class,
Class.forName("net.openid.appauth.AppAuthConfiguration").kotlin,
),
)
koinApplication {
modules(externalModule, featureAccountSetupModule)
androidContext(RuntimeEnvironment.getApplication())
checkModules()
checkModules {
val authStateStorage = InMemoryAuthStateStorage()
withParameter<AccountValidationContract.ViewModel>(named(NAME_INCOMING_VALIDATION)) { authStateStorage }
withParameter<AccountValidationContract.ViewModel>(named(NAME_OUTGOING_VALIDATION)) { authStateStorage }
withParameter<ValidateServerSettings> { authStateStorage }
}
}
}
}

View file

@ -44,6 +44,7 @@ class CreateAccountTest {
password = "password",
clientCertificateAlias = null,
)
val authorizationState = "authorization state"
val options = AccountOptions(
accountName = "accountName",
displayName = "displayName",
@ -53,7 +54,13 @@ class CreateAccountTest {
showNotification = true,
)
val result = createAccount.execute(emailAddress, incomingServerSettings, outgoingServerSettings, options)
val result = createAccount.execute(
emailAddress,
incomingServerSettings,
outgoingServerSettings,
authorizationState,
options,
)
assertThat(result).isEqualTo("uuid")
assertThat(recordedAccount).isEqualTo(
@ -61,6 +68,7 @@ class CreateAccountTest {
emailAddress = emailAddress,
incomingServerSettings = incomingServerSettings,
outgoingServerSettings = outgoingServerSettings,
authorizationState = authorizationState,
options = options,
),
)

View file

@ -1,5 +1,6 @@
package app.k9mail.feature.account.setup.domain.usecase
import app.k9mail.feature.account.setup.ui.validation.InMemoryAuthStateStorage
import assertk.assertThat
import assertk.assertions.isEqualTo
import com.fsck.k9.mail.AuthType
@ -11,10 +12,12 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test
class ValidateServerSettingsTest {
private val authStateStorage = InMemoryAuthStateStorage()
@Test
fun `should check with imap validator when protocol is imap`() = runTest {
val testSubject = ValidateServerSettings(
authStateStorage = authStateStorage,
imapValidator = { _, _ -> ServerSettingsValidationResult.Success },
pop3Validator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed POP3")) },
smtpValidator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed SMTP")) },
@ -29,6 +32,7 @@ class ValidateServerSettingsTest {
fun `should check with imap validator when protocol is imap and return failure`() = runTest {
val failure = ServerSettingsValidationResult.ServerError("Failed")
val testSubject = ValidateServerSettings(
authStateStorage = authStateStorage,
imapValidator = { _, _ -> failure },
pop3Validator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed POP3")) },
smtpValidator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed SMTP")) },
@ -42,6 +46,7 @@ class ValidateServerSettingsTest {
@Test
fun `should check with pop3 validator when protocol is pop3`() = runTest {
val testSubject = ValidateServerSettings(
authStateStorage = authStateStorage,
imapValidator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed IMAP")) },
pop3Validator = { _, _ -> ServerSettingsValidationResult.Success },
smtpValidator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed SMTP")) },
@ -56,6 +61,7 @@ class ValidateServerSettingsTest {
fun `should check with pop3 validator when protocol is pop3 and return failure`() = runTest {
val failure = ServerSettingsValidationResult.ServerError("Failed POP3")
val testSubject = ValidateServerSettings(
authStateStorage = authStateStorage,
imapValidator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed IMAP")) },
pop3Validator = { _, _ -> failure },
smtpValidator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed SMTP")) },
@ -69,6 +75,7 @@ class ValidateServerSettingsTest {
@Test
fun `should check with smtp validator when protocol is smtp`() = runTest {
val testSubject = ValidateServerSettings(
authStateStorage = authStateStorage,
imapValidator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed IMAP")) },
pop3Validator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed POP3")) },
smtpValidator = { _, _ -> ServerSettingsValidationResult.Success },
@ -83,6 +90,7 @@ class ValidateServerSettingsTest {
fun `should check with smtp validator when protocol is smtp and return failure`() = runTest {
val failure = ServerSettingsValidationResult.ServerError("Failed SMTP")
val testSubject = ValidateServerSettings(
authStateStorage = authStateStorage,
imapValidator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed IMAP")) },
pop3Validator = { _, _ -> ServerSettingsValidationResult.NetworkError(IOException("Failed POP3")) },
smtpValidator = { _, _ -> failure },

View file

@ -7,11 +7,6 @@ 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.autodiscovery.FakeAccountAutoDiscoveryViewModel
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 app.k9mail.feature.account.setup.ui.validation.FakeAccountValidationViewModel
import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlinx.coroutines.test.runTest
@ -22,12 +17,6 @@ class AccountSetupScreenKtTest : ComposeTest() {
@Test
fun `should display correct screen for every setup step`() = runTest {
val viewModel = FakeAccountSetupViewModel()
val autoDiscoveryViewModel = FakeAccountAutoDiscoveryViewModel()
val incomingViewModel = FakeAccountIncomingConfigViewModel()
val incomingValidationViewModel = FakeAccountValidationViewModel()
val outgoingViewModel = FakeAccountOutgoingConfigViewModel()
val outgoingValidationViewModel = FakeAccountValidationViewModel()
val optionsViewModel = FakeAccountOptionsViewModel()
setContent {
ThunderbirdTheme {
@ -35,12 +24,6 @@ class AccountSetupScreenKtTest : ComposeTest() {
onFinish = { },
onBack = { },
viewModel = viewModel,
autoDiscoveryViewModel = autoDiscoveryViewModel,
incomingViewModel = incomingViewModel,
incomingValidationViewModel = incomingValidationViewModel,
outgoingViewModel = outgoingViewModel,
outgoingValidationViewModel = outgoingValidationViewModel,
optionsViewModel = optionsViewModel,
)
}
}
@ -54,13 +37,7 @@ class AccountSetupScreenKtTest : ComposeTest() {
@Test
fun `should delegate navigation effects`() = runTest {
val initialState = State()
val viewModel = FakeAccountSetupViewModel(initialState)
val autoDiscoveryViewModel = FakeAccountAutoDiscoveryViewModel()
val incomingViewModel = FakeAccountIncomingConfigViewModel()
val incomingValidationViewModel = FakeAccountValidationViewModel()
val outgoingViewModel = FakeAccountOutgoingConfigViewModel()
val outgoingValidationViewModel = FakeAccountValidationViewModel()
val optionsViewModel = FakeAccountOptionsViewModel()
val viewModel = FakeAccountSetupViewModel(initialState = initialState)
var onFinishCounter = 0
var onBackCounter = 0
@ -70,12 +47,6 @@ class AccountSetupScreenKtTest : ComposeTest() {
onFinish = { onFinishCounter++ },
onBack = { onBackCounter++ },
viewModel = viewModel,
autoDiscoveryViewModel = autoDiscoveryViewModel,
incomingViewModel = incomingViewModel,
incomingValidationViewModel = incomingValidationViewModel,
outgoingViewModel = outgoingViewModel,
outgoingValidationViewModel = outgoingValidationViewModel,
optionsViewModel = optionsViewModel,
)
}
}

View file

@ -1,32 +1,87 @@
package app.k9mail.feature.account.setup.ui
import app.cash.turbine.testIn
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
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
import app.k9mail.core.ui.compose.testing.MainDispatcherRule
import app.k9mail.feature.account.setup.domain.entity.AccountOptions
import app.k9mail.feature.account.setup.domain.entity.AuthenticationType
import app.k9mail.feature.account.setup.domain.entity.ConnectionSecurity
import app.k9mail.feature.account.setup.domain.entity.EmailCheckFrequency
import app.k9mail.feature.account.setup.domain.entity.EmailDisplayCount
import app.k9mail.feature.account.setup.domain.entity.IncomingProtocolType
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.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.autodiscovery.AccountAutoDiscoveryContract
import app.k9mail.feature.account.setup.ui.autodiscovery.FakeAccountAutoDiscoveryViewModel
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract
import app.k9mail.feature.account.setup.ui.incoming.FakeAccountIncomingConfigViewModel
import app.k9mail.feature.account.setup.ui.incoming.toServerSettings
import app.k9mail.feature.account.setup.ui.incoming.toValidationState
import app.k9mail.feature.account.setup.ui.options.AccountOptionsContract
import app.k9mail.feature.account.setup.ui.options.FakeAccountOptionsViewModel
import app.k9mail.feature.account.setup.ui.outgoing.AccountOutgoingConfigContract
import app.k9mail.feature.account.setup.ui.outgoing.FakeAccountOutgoingConfigViewModel
import app.k9mail.feature.account.setup.ui.outgoing.toServerSettings
import app.k9mail.feature.account.setup.ui.outgoing.toValidationState
import app.k9mail.feature.account.setup.ui.validation.FakeAccountValidationViewModel
import app.k9mail.feature.account.setup.ui.validation.InMemoryAuthStateStorage
import assertk.assertThat
import assertk.assertions.assertThatAndTurbinesConsumed
import assertk.assertions.isEqualTo
import assertk.assertions.isNull
import assertk.assertions.prop
import com.fsck.k9.mail.ServerSettings
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import app.k9mail.autodiscovery.api.AuthenticationType as AutoDiscoveryAuthenticationType
import app.k9mail.autodiscovery.api.ConnectionSecurity as AutoDiscoveryConnectionSecurity
@Suppress("LongMethod")
class AccountSetupViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Suppress("LongMethod")
private val autoDiscoveryViewModel = FakeAccountAutoDiscoveryViewModel()
private val incomingViewModel = FakeAccountIncomingConfigViewModel()
private val incomingValidationViewModel = FakeAccountValidationViewModel()
private val outgoingViewModel = FakeAccountOutgoingConfigViewModel()
private val outgoingValidationViewModel = FakeAccountValidationViewModel()
private val optionsViewModel = FakeAccountOptionsViewModel()
private val authStateStorage = InMemoryAuthStateStorage()
@Test
fun `should forward step state on next event`() = runTest {
var createAccountEmailAddress: String? = null
var createAccountIncomingServerSettings: ServerSettings? = null
var createAccountOutgoingServerSettings: ServerSettings? = null
var createAccountAuthorizationState: String? = null
var createAccountOptions: AccountOptions? = null
val viewModel = AccountSetupViewModel(
createAccount = { _, _, _, _ -> "accountUuid" },
createAccount = { emailAddress, incomingServerSettings, outgoingServerSettings, authState, options ->
createAccountEmailAddress = emailAddress
createAccountIncomingServerSettings = incomingServerSettings
createAccountOutgoingServerSettings = outgoingServerSettings
createAccountAuthorizationState = authState
createAccountOptions = options
"accountUuid"
},
autoDiscoveryViewModel = autoDiscoveryViewModel,
incomingViewModel = incomingViewModel,
incomingValidationViewModel = incomingValidationViewModel,
outgoingViewModel = outgoingViewModel,
outgoingValidationViewModel = outgoingValidationViewModel,
optionsViewModel = optionsViewModel,
authStateStorage = authStateStorage,
)
val stateTurbine = viewModel.state.testIn(backgroundScope)
val effectTurbine = viewModel.effect.testIn(backgroundScope)
@ -40,21 +95,40 @@ class AccountSetupViewModelTest {
prop(State::setupStep).isEqualTo(SetupStep.AUTO_CONFIG)
}
autoDiscoveryViewModel.initState(AUTODISCOVERY_STATE)
viewModel.event(
AccountSetupContract.Event.OnAutoDiscoveryFinished(
state = AccountAutoDiscoveryContract.State(),
state = AUTODISCOVERY_STATE,
isAutomaticConfig = false,
),
)
assertThat(effectTurbine.awaitItem())
.isEqualTo(Effect.UpdateIncomingConfig(AccountIncomingConfigContract.State()))
val expectedIncomingConfigState = AccountIncomingConfigContract.State(
protocolType = IncomingProtocolType.IMAP,
server = StringInputField(INCOMING_SERVER_NAME),
security = ConnectionSecurity.TLS,
port = NumberInputField(INCOMING_SERVER_PORT.toLong()),
authenticationType = AuthenticationType.PasswordEncrypted,
username = StringInputField(USERNAME),
password = StringInputField(PASSWORD),
)
assertThat(incomingViewModel.state.value).isEqualTo(expectedIncomingConfigState)
assertThat(effectTurbine.awaitItem())
.isEqualTo(Effect.UpdateOutgoingConfig(AccountOutgoingConfigContract.State()))
val expectedOutgoingConfigState = AccountOutgoingConfigContract.State(
server = StringInputField(OUTGOING_SERVER_NAME),
security = ConnectionSecurity.TLS,
port = NumberInputField(OUTGOING_SERVER_PORT.toLong()),
authenticationType = AuthenticationType.PasswordEncrypted,
username = StringInputField(USERNAME),
password = StringInputField(PASSWORD),
)
assertThat(outgoingViewModel.state.value).isEqualTo(expectedOutgoingConfigState)
assertThat(effectTurbine.awaitItem())
.isEqualTo(Effect.UpdateOptions(AccountOptionsContract.State()))
assertThat(optionsViewModel.state.value).isEqualTo(
AccountOptionsContract.State(
accountName = StringInputField(EMAIL_ADDRESS),
),
)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
@ -65,7 +139,7 @@ class AccountSetupViewModelTest {
viewModel.event(AccountSetupContract.Event.OnNext)
assertThat(effectTurbine.awaitItem()).isEqualTo(Effect.UpdateIncomingConfigValidation)
assertThat(incomingValidationViewModel.state.value).isEqualTo(expectedIncomingConfigState.toValidationState())
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
@ -85,7 +159,7 @@ class AccountSetupViewModelTest {
viewModel.event(AccountSetupContract.Event.OnNext)
assertThat(effectTurbine.awaitItem()).isEqualTo(Effect.UpdateOutgoingConfigValidation)
assertThat(outgoingValidationViewModel.state.value).isEqualTo(expectedOutgoingConfigState.toValidationState())
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
@ -103,37 +177,54 @@ class AccountSetupViewModelTest {
prop(State::setupStep).isEqualTo(SetupStep.OPTIONS)
}
viewModel.event(AccountSetupContract.Event.OnNext)
assertThatAndTurbinesConsumed(
actual = effectTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(Effect.CollectExternalStates)
}
viewModel.event(
AccountSetupContract.Event.OnStateCollected(
autoDiscoveryState = AccountAutoDiscoveryContract.State(),
incomingState = AccountIncomingConfigContract.State(),
outgoingState = AccountOutgoingConfigContract.State(),
optionsState = AccountOptionsContract.State(),
optionsViewModel.initState(
optionsViewModel.state.value.copy(
accountName = StringInputField("account name"),
displayName = StringInputField("display name"),
emailSignature = StringInputField("signature"),
checkFrequency = EmailCheckFrequency.EVERY_15_MINUTES,
messageDisplayCount = EmailDisplayCount.MESSAGES_100,
showNotification = true,
),
)
viewModel.event(AccountSetupContract.Event.OnNext)
assertThatAndTurbinesConsumed(
actual = effectTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(Effect.NavigateNext("accountUuid"))
}
assertThat(createAccountEmailAddress).isEqualTo(EMAIL_ADDRESS)
assertThat(createAccountIncomingServerSettings).isEqualTo(expectedIncomingConfigState.toServerSettings())
assertThat(createAccountOutgoingServerSettings).isEqualTo(expectedOutgoingConfigState.toServerSettings())
assertThat(createAccountAuthorizationState).isNull()
assertThat(createAccountOptions).isEqualTo(
AccountOptions(
accountName = "account name",
displayName = "display name",
emailSignature = "signature",
checkFrequencyInMinutes = 15,
messageDisplayCount = 100,
showNotification = true,
),
)
}
@Test
fun `should rewind step state on back event`() = runTest {
val initialState = State(setupStep = SetupStep.OPTIONS)
val viewModel = AccountSetupViewModel(
createAccount = { _, _, _, _ -> "accountUuid" },
createAccount = { _, _, _, _, _ -> "accountUuid" },
autoDiscoveryViewModel = autoDiscoveryViewModel,
incomingViewModel = FakeAccountIncomingConfigViewModel(),
incomingValidationViewModel = FakeAccountValidationViewModel(),
outgoingViewModel = FakeAccountOutgoingConfigViewModel(),
outgoingValidationViewModel = FakeAccountValidationViewModel(),
optionsViewModel = FakeAccountOptionsViewModel(),
authStateStorage = authStateStorage,
initialState = initialState,
)
val stateTurbine = viewModel.state.testIn(backgroundScope)
@ -202,4 +293,44 @@ class AccountSetupViewModelTest {
isEqualTo(Effect.NavigateBack)
}
}
companion object {
private const val EMAIL_ADDRESS = "test@domain.example"
private const val USERNAME = EMAIL_ADDRESS
private const val PASSWORD = "password"
private const val INCOMING_SERVER_NAME = "imap.domain.example"
private const val INCOMING_SERVER_PORT = 993
private const val OUTGOING_SERVER_NAME = "smtp.domain.example"
private const val OUTGOING_SERVER_PORT = 465
private val INCOMING_SERVER_SETTINGS = ImapServerSettings(
hostname = INCOMING_SERVER_NAME.toHostname(),
port = INCOMING_SERVER_PORT.toPort(),
connectionSecurity = AutoDiscoveryConnectionSecurity.TLS,
authenticationTypes = listOf(AutoDiscoveryAuthenticationType.PasswordEncrypted),
username = USERNAME,
)
private val OUTGOING_SERVER_SETTINGS = SmtpServerSettings(
hostname = OUTGOING_SERVER_NAME.toHostname(),
port = OUTGOING_SERVER_PORT.toPort(),
connectionSecurity = AutoDiscoveryConnectionSecurity.TLS,
authenticationTypes = listOf(AutoDiscoveryAuthenticationType.PasswordEncrypted),
username = USERNAME,
)
private val AUTODISCOVERY_RESULT = AutoDiscoveryResult.Settings(
incomingServerSettings = INCOMING_SERVER_SETTINGS,
outgoingServerSettings = OUTGOING_SERVER_SETTINGS,
isTrusted = true,
source = "test",
)
private val AUTODISCOVERY_STATE = AccountAutoDiscoveryContract.State(
configStep = AccountAutoDiscoveryContract.ConfigStep.PASSWORD,
emailAddress = StringInputField(EMAIL_ADDRESS),
password = StringInputField(PASSWORD),
autoDiscoverySettings = AUTODISCOVERY_RESULT,
)
}
}

View file

@ -4,8 +4,23 @@ import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
import app.k9mail.feature.account.setup.ui.AccountSetupContract.Effect
import app.k9mail.feature.account.setup.ui.AccountSetupContract.Event
import app.k9mail.feature.account.setup.ui.AccountSetupContract.State
import app.k9mail.feature.account.setup.ui.autodiscovery.FakeAccountAutoDiscoveryViewModel
import app.k9mail.feature.account.setup.ui.incoming.AccountIncomingConfigContract
import app.k9mail.feature.account.setup.ui.incoming.FakeAccountIncomingConfigViewModel
import app.k9mail.feature.account.setup.ui.options.AccountOptionsContract
import app.k9mail.feature.account.setup.ui.options.FakeAccountOptionsViewModel
import app.k9mail.feature.account.setup.ui.outgoing.AccountOutgoingConfigContract
import app.k9mail.feature.account.setup.ui.outgoing.FakeAccountOutgoingConfigViewModel
import app.k9mail.feature.account.setup.ui.validation.AccountValidationContract
import app.k9mail.feature.account.setup.ui.validation.FakeAccountValidationViewModel
internal class FakeAccountSetupViewModel(
override val autoDiscoveryViewModel: FakeAccountAutoDiscoveryViewModel = FakeAccountAutoDiscoveryViewModel(),
override val incomingViewModel: AccountIncomingConfigContract.ViewModel = FakeAccountIncomingConfigViewModel(),
override val incomingValidationViewModel: AccountValidationContract.ViewModel = FakeAccountValidationViewModel(),
override val outgoingViewModel: AccountOutgoingConfigContract.ViewModel = FakeAccountOutgoingConfigViewModel(),
override val outgoingValidationViewModel: AccountValidationContract.ViewModel = FakeAccountValidationViewModel(),
override val optionsViewModel: AccountOptionsContract.ViewModel = FakeAccountOptionsViewModel(),
initialState: State = State(),
) : BaseViewModel<State, Event, Effect>(initialState), AccountSetupContract.ViewModel {

View file

@ -33,6 +33,7 @@ class AccountAutoDiscoveryViewModelTest {
delay(50)
AutoDiscoveryResult.NoUsableSettingsFound
},
oAuthViewModel = FakeAccountOAuthViewModel(),
)
@Test
@ -97,6 +98,7 @@ class AccountAutoDiscoveryViewModelTest {
delay(50)
autoDiscoverySettings
},
oAuthViewModel = FakeAccountOAuthViewModel(),
initialState = initialState,
)
val stateTurbine = viewModel.state.testIn(backgroundScope)
@ -153,6 +155,7 @@ class AccountAutoDiscoveryViewModelTest {
delay(50)
AutoDiscoveryResult.UnexpectedException(discoveryError)
},
oAuthViewModel = FakeAccountOAuthViewModel(),
initialState = initialState,
)
val stateTurbine = viewModel.state.testIn(backgroundScope)
@ -234,6 +237,7 @@ class AccountAutoDiscoveryViewModelTest {
emailAddressAnswer = ValidationResult.Failure(TestError),
),
getAutoDiscovery = { AutoDiscoveryResult.NoUsableSettingsFound },
oAuthViewModel = FakeAccountOAuthViewModel(),
initialState = initialState,
)
@ -317,6 +321,7 @@ class AccountAutoDiscoveryViewModelTest {
passwordAnswer = ValidationResult.Failure(TestError),
),
getAutoDiscovery = { AutoDiscoveryResult.NoUsableSettingsFound },
oAuthViewModel = FakeAccountOAuthViewModel(),
initialState = initialState,
)
val stateTurbine = viewModel.state.testIn(backgroundScope)

View file

@ -1,17 +1,19 @@
package app.k9mail.feature.account.setup.ui.autodiscovery
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.Effect
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.Event
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.State
import app.k9mail.feature.account.setup.ui.autodiscovery.AccountAutoDiscoveryContract.ViewModel
class FakeAccountAutoDiscoveryViewModel(
initialState: State = State(),
) : BaseViewModel<State, Event, Effect>(initialState), ViewModel {
) : BaseViewModel<State, Event, Effect>(initialState), AccountAutoDiscoveryContract.ViewModel {
val events = mutableListOf<Event>()
override val oAuthViewModel: AccountOAuthContract.ViewModel = FakeAccountOAuthViewModel()
override fun initState(state: State) {
updateState { state }
}

View file

@ -0,0 +1,26 @@
package app.k9mail.feature.account.setup.ui.autodiscovery
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Effect
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Event
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.State
internal class FakeAccountOAuthViewModel(
initialState: State = State(),
) : BaseViewModel<State, Event, Effect>(initialState), AccountOAuthContract.ViewModel {
val events = mutableListOf<Event>()
override fun initState(state: State) {
updateState { state }
}
override fun event(event: Event) {
events.add(event)
}
fun effect(effect: Effect) {
emitEffect(effect)
}
}

View file

@ -16,6 +16,7 @@ data class ServerSettings @JvmOverloads constructor(
) {
val isMissingCredentials: Boolean = when (authenticationType) {
AuthType.EXTERNAL -> clientCertificateAlias == null
AuthType.XOAUTH2 -> username.isBlank()
else -> username.isNotBlank() && password == null
}