Add AccountOAuthScreen

This commit is contained in:
Wolf-Martell Montwé 2023-06-21 10:50:50 +02:00 committed by Wolf-Martell Montwé
parent 93cf883fca
commit e658b8256b
No known key found for this signature in database
GPG key ID: 6D45B21512ACBF72
16 changed files with 706 additions and 0 deletions

View file

@ -1,3 +1,5 @@
@file:Suppress("TooManyFunctions")
package app.k9mail.core.ui.compose.testing
import androidx.annotation.StringRes
@ -55,6 +57,25 @@ fun ComposeTest.onNodeWithText(
useUnmergedTree: Boolean = false,
) = composeTestRule.onNodeWithText(text, substring, ignoreCase, useUnmergedTree)
fun ComposeTest.onNodeWithText(
@StringRes resourceId: Int,
substring: Boolean = false,
ignoreCase: Boolean = false,
useUnmergedTree: Boolean = false,
) = composeTestRule.onNodeWithText(getString(resourceId), substring, ignoreCase, useUnmergedTree)
fun ComposeTest.onNodeWithTextIgnoreCase(
text: String,
substring: Boolean = false,
useUnmergedTree: Boolean = false,
) = composeTestRule.onNodeWithText(text, substring, true, useUnmergedTree)
fun ComposeTest.onNodeWithTextIgnoreCase(
@StringRes resourceId: Int,
substring: Boolean = false,
useUnmergedTree: Boolean = false,
) = composeTestRule.onNodeWithText(getString(resourceId), substring, true, useUnmergedTree)
fun ComposeTest.onAllNodesWithText(
text: String,
substring: Boolean = false,

View file

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

View file

@ -6,8 +6,10 @@ import app.k9mail.feature.account.oauth.domain.DomainContract
import app.k9mail.feature.account.oauth.domain.DomainContract.UseCase
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.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
@ -34,4 +36,10 @@ val featureAccountOAuthModule: Module = module {
configurationProvider = get(),
)
}
viewModel {
AccountOAuthViewModel(
getOAuthRequestIntent = get(),
)
}
}

View file

@ -0,0 +1,5 @@
package app.k9mail.feature.account.oauth.domain.entity
data class AuthorizationState(
val state: String? = null,
)

View file

@ -0,0 +1,9 @@
package app.k9mail.feature.account.oauth.domain.entity
sealed interface OAuthResult {
data class Success(
val authorizationState: AuthorizationState,
) : OAuthResult
object Failure : OAuthResult
}

View file

@ -0,0 +1,86 @@
package app.k9mail.feature.account.oauth.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
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.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
@Composable
internal fun AccountOAuthContent(
state: State,
onEvent: (Event) -> Unit,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
) {
val resources = LocalContext.current.resources
ContentListView(
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),
)
}
} 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) },
)
}
} else {
item(key = "sign_in") {
SignInItem(
emailAddress = state.emailAddress,
onSignInClick = { onEvent(Event.SignInClicked) },
)
}
}
}
}
@Composable
@DevicePreviews
internal fun AccountOAuthContentK9Preview() {
K9Theme {
AccountOAuthContent(
state = State(),
onEvent = {},
contentPadding = PaddingValues(),
)
}
}
@Composable
@DevicePreviews
internal fun AccountOAuthContentThunderbirdPreview() {
ThunderbirdTheme {
AccountOAuthContent(
state = State(),
onEvent = {},
contentPadding = PaddingValues(),
)
}
}

View file

@ -0,0 +1,49 @@
package app.k9mail.feature.account.oauth.ui
import android.content.Intent
import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel
import app.k9mail.feature.account.common.ui.WizardNavigationBarState
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationState
interface AccountOAuthContract {
interface ViewModel : UnidirectionalViewModel<State, Event, Effect> {
fun initState(state: State)
}
data class State(
val hostname: String = "",
val emailAddress: String = "",
val authorizationState: AuthorizationState = AuthorizationState(),
val wizardNavigationBarState: WizardNavigationBarState = WizardNavigationBarState(
isNextEnabled = false,
),
val isGoogleSignIn: Boolean = false,
val error: Error? = null,
val isLoading: Boolean = false,
)
sealed interface Event {
object SignInClicked : Event
object OnNextClicked : Event
object OnBackClicked : Event
object OnRetryClicked : Event
}
sealed interface Effect {
data class LaunchOAuth(
val intent: Intent,
) : Effect
data class NavigateNext(
val state: AuthorizationState,
) : Effect
object NavigateBack : Effect
}
sealed interface Error {
object NotSupported : Error
object NetworkError : Error
object UnknownError : Error
}
}

View file

@ -0,0 +1,69 @@
package app.k9mail.feature.account.oauth.ui
import android.app.Activity
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.common.ui.WizardNavigationBarState
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(
onOAuthResult: (OAuthResult) -> Unit,
viewModel: ViewModel,
modifier: Modifier = Modifier,
) {
val oAuthLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
) {
if (it.resultCode == Activity.RESULT_OK && it.data != null) {
// TODO handle success
}
// TODO handle error
}
val (state, dispatch) = viewModel.observe { effect ->
when (effect) {
is Effect.NavigateNext -> onOAuthResult(OAuthResult.Success(effect.state))
is Effect.NavigateBack -> onOAuthResult(OAuthResult.Failure)
is Effect.LaunchOAuth -> oAuthLauncher.launch(effect.intent)
}
}
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 = { dispatch(Event.OnNextClicked) },
onBackClick = { dispatch(Event.OnBackClicked) },
)
},
modifier = modifier,
) { innerPadding ->
AccountOAuthContent(
state = state.value,
onEvent = { dispatch(it) },
contentPadding = innerPadding,
)
}
}

View file

@ -0,0 +1,64 @@
package app.k9mail.feature.account.oauth.ui
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
import app.k9mail.feature.account.oauth.domain.DomainContract.UseCase.GetOAuthRequestIntent
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationIntentResult
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Effect
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Error
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.AccountOAuthContract.ViewModel
class AccountOAuthViewModel(
initialState: State = State(),
private val getOAuthRequestIntent: GetOAuthRequestIntent,
) : BaseViewModel<State, Event, Effect>(initialState), ViewModel {
override fun initState(state: State) {
updateState {
state.copy()
}
}
override fun event(event: Event) {
when (event) {
Event.SignInClicked -> launchOAuth()
Event.OnNextClicked -> TODO()
Event.OnBackClicked -> navigateBack()
Event.OnRetryClicked -> {
updateState { state ->
state.copy(
error = null,
)
}
launchOAuth()
}
}
}
private fun launchOAuth() {
val result = getOAuthRequestIntent.execute(
hostname = state.value.hostname,
emailAddress = state.value.emailAddress,
)
when (result) {
AuthorizationIntentResult.NotSupported -> {
updateState { state ->
state.copy(
error = Error.NotSupported,
)
}
}
is AuthorizationIntentResult.Success -> {
emitEffect(Effect.LaunchOAuth(result.intent))
}
}
}
private fun navigateBack() = emitEffect(Effect.NavigateBack)
}

View file

@ -0,0 +1,23 @@
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,
modifier: Modifier = Modifier,
) {
ListItem(
modifier = modifier,
) {
SignInView(
emailAddress = emailAddress,
onSignInClick = onSignInClick,
)
}
}

View file

@ -0,0 +1,46 @@
package app.k9mail.feature.account.oauth.ui.view
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
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,
modifier: Modifier = Modifier,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
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,
)
Button(
text = stringResource(id = R.string.account_oauth_sign_in_button),
onClick = onSignInClick,
)
}
}

View file

@ -1,5 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_oauth_title">K-9 Mail</string>
<string name="account_oauth_button_next">Next</string>
<string name="account_oauth_button_back">Back</string>
<string name="account_oauth_sign_in_title">Sign in with OAuth2</string>
<string name="account_oauth_sign_in_description">We\'ll redirect you to your email provider to sign in. You need to grant the app access to your email account.</string>
<string name="account_oauth_sign_in_button">Sign in</string>
<string name="account_oauth_sign_in_with_google_button">Sign in with Google</string>
<string name="account_oauth_sign_in_with_google_button_loading">Signing in with Google …</string>
<string name="account_oauth_loading_message">Signing in using OAuth</string>
<string name="account_oauth_loading_error">OAuth sign in failed</string>
</resources>

View file

@ -0,0 +1,95 @@
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,32 @@
package app.k9mail.feature.account.oauth.ui
import app.k9mail.feature.account.common.ui.WizardNavigationBarState
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationState
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.State
import assertk.all
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.prop
import org.junit.Test
class AccountOAuthStateTest {
@Test
fun `should set default values`() {
val state = State()
assertThat(state).all {
prop(State::hostname).isEqualTo("")
prop(State::emailAddress).isEqualTo("")
prop(State::authorizationState).isEqualTo(AuthorizationState())
prop(State::wizardNavigationBarState).isEqualTo(
WizardNavigationBarState(
isNextEnabled = false,
),
)
prop(State::isGoogleSignIn).isEqualTo(false)
prop(State::error).isEqualTo(null)
prop(State::isLoading).isEqualTo(false)
}
}
}

View file

@ -0,0 +1,164 @@
package app.k9mail.feature.account.oauth.ui
import android.content.Intent
import app.cash.turbine.testIn
import app.k9mail.core.ui.compose.testing.MainDispatcherRule
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationIntentResult
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Effect
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Error
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Event
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.State
import assertk.assertThat
import assertk.assertions.assertThatAndTurbinesConsumed
import assertk.assertions.isEqualTo
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class AccountOAuthViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val testSubject = AccountOAuthViewModel(
getOAuthRequestIntent = { _, _ ->
AuthorizationIntentResult.Success(intent = Intent())
},
)
@Test
fun `should launch OAuth when SignInClicked event received`() = runTest {
val initialState = State(
hostname = "example.com",
emailAddress = "test@example.com",
)
val intent = Intent()
val testSubject = AccountOAuthViewModel(
getOAuthRequestIntent = { _, _ ->
AuthorizationIntentResult.Success(intent = intent)
},
initialState = initialState,
)
val stateTurbine = testSubject.state.testIn(backgroundScope)
val effectTurbine = testSubject.effect.testIn(backgroundScope)
val turbines = listOf(stateTurbine, effectTurbine)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(initialState)
}
testSubject.event(Event.SignInClicked)
assertThatAndTurbinesConsumed(
actual = effectTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(Effect.LaunchOAuth(intent))
}
}
@Test
fun `should show error when SignInClicked event received and OAuth is not supported`() = runTest {
val initialState = State(
hostname = "example.com",
emailAddress = "test@example.com",
)
val testSubject = AccountOAuthViewModel(
getOAuthRequestIntent = { _, _ ->
AuthorizationIntentResult.NotSupported
},
initialState = initialState,
)
val stateTurbine = testSubject.state.testIn(backgroundScope)
val effectTurbine = testSubject.effect.testIn(backgroundScope)
val turbines = listOf(stateTurbine, effectTurbine)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(initialState)
}
testSubject.event(Event.SignInClicked)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(initialState.copy(error = Error.NotSupported))
}
}
@Test
fun `should remove error and launch OAuth when OnRetryClicked event received`() = runTest {
val initialState = State(
hostname = "example.com",
emailAddress = "test@example.com",
error = Error.NotSupported,
)
val intent = Intent()
val testSubject = AccountOAuthViewModel(
getOAuthRequestIntent = { _, _ ->
AuthorizationIntentResult.Success(intent = intent)
},
initialState = initialState,
)
val stateTurbine = testSubject.state.testIn(backgroundScope)
val effectTurbine = testSubject.effect.testIn(backgroundScope)
val turbines = listOf(stateTurbine, effectTurbine)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(initialState)
}
testSubject.event(Event.OnRetryClicked)
assertThat(stateTurbine.awaitItem()).isEqualTo(
initialState.copy(error = null)
)
assertThatAndTurbinesConsumed(
actual = effectTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(Effect.LaunchOAuth(intent))
}
}
@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)
}
}
}

View file

@ -0,0 +1,26 @@
package app.k9mail.feature.account.oauth.ui
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
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
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.ViewModel
class FakeAccountOAuthViewModel(
initialState: State = State(),
) : BaseViewModel<State, Event, Effect>(initialState), 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)
}
}