Merge pull request #7079 from thundernest/add_account_setup_oauth

Add account setup oauth
This commit is contained in:
Wolf-Martell Montwé 2023-07-26 12:43:29 +00:00 committed by GitHub
commit 0c0e57f7ba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 1685 additions and 47 deletions

View file

@ -1,5 +1,6 @@
package com.fsck.k9.activity.setup
@Deprecated("Could be removed with the legacy account setup UI")
object GoogleOAuthHelper {
fun isGoogle(hostname: String): Boolean {
return hostname.lowercase().endsWith(".gmail.com") ||

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,9 +19,11 @@ 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)
implementation(libs.timber)
testImplementation(projects.core.ui.compose.testing)
}

View file

@ -2,12 +2,17 @@ package app.k9mail.feature.account.oauth
import app.k9mail.core.common.coreCommonModule
import app.k9mail.feature.account.oauth.data.AuthorizationRepository
import app.k9mail.feature.account.oauth.data.AuthorizationStateRepository
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.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.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
@ -26,6 +31,10 @@ val featureAccountOAuthModule: Module = module {
)
}
factory<DomainContract.AuthorizationStateRepository> {
AuthorizationStateRepository()
}
factory<UseCase.SuggestServerName> { SuggestServerName() }
factory<UseCase.GetOAuthRequestIntent> {
@ -34,4 +43,16 @@ val featureAccountOAuthModule: Module = module {
configurationProvider = get(),
)
}
factory<UseCase.FinishOAuthSignIn> { FinishOAuthSignIn(repository = get()) }
factory<UseCase.CheckIsGoogleSignIn> { CheckIsGoogleSignIn() }
viewModel {
AccountOAuthViewModel(
getOAuthRequestIntent = get(),
finishOAuthSignIn = get(),
checkIsGoogleSignIn = get(),
)
}
}

View file

@ -0,0 +1,24 @@
package app.k9mail.feature.account.oauth.data
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationState
import net.openid.appauth.AuthState
import org.json.JSONException
import timber.log.Timber
fun AuthState.toAuthorizationState(): AuthorizationState {
return try {
AuthorizationState(state = jsonSerializeString())
} catch (e: JSONException) {
Timber.e(e, "Error serializing AuthorizationState")
AuthorizationState()
}
}
fun AuthorizationState.toAuthState(): AuthState {
return try {
state?.let { AuthState.jsonDeserialize(it) } ?: AuthState()
} catch (e: JSONException) {
Timber.e(e, "Error deserializing AuthorizationState")
AuthState()
}
}

View file

@ -5,10 +5,17 @@ import androidx.core.net.toUri
import app.k9mail.core.common.oauth.OAuthConfiguration
import app.k9mail.feature.account.oauth.domain.DomainContract
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationIntentResult
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationResult
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationState
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
import timber.log.Timber
class AuthorizationRepository(
private val service: AuthorizationService,
@ -23,6 +30,46 @@ class AuthorizationRepository(
)
}
override suspend fun getAuthorizationResponse(intent: Intent): AuthorizationResponse? {
return try {
AuthorizationResponse.fromIntent(intent)
} catch (e: IllegalArgumentException) {
Timber.e(e, "Error deserializing AuthorizationResponse")
null
}
}
override suspend fun getAuthorizationException(intent: Intent): AuthorizationException? {
return try {
AuthorizationException.fromIntent(intent)
} catch (e: IllegalArgumentException) {
Timber.e(e, "Error deserializing AuthorizationException")
null
}
}
override suspend fun getExchangeToken(
authorizationState: AuthorizationState,
response: AuthorizationResponse,
): AuthorizationResult = suspendCoroutine { continuation ->
val tokenRequest = response.createTokenExchangeRequest()
val authState = authorizationState.toAuthState()
service.performTokenRequest(tokenRequest) { tokenResponse, authorizationException ->
authState.update(tokenResponse, authorizationException)
val result = if (authorizationException != null) {
AuthorizationResult.Failure(authorizationException)
} else if (tokenResponse != null) {
AuthorizationResult.Success(authState.toAuthorizationState())
} else {
AuthorizationResult.Failure(Exception("Unknown error"))
}
continuation.resume(result)
}
}
private fun createAuthorizationRequestIntent(configuration: OAuthConfiguration, emailAddress: String): Intent {
val serviceConfig = AuthorizationServiceConfiguration(
configuration.authorizationEndpoint.toUri(),

View file

@ -0,0 +1,12 @@
package app.k9mail.feature.account.oauth.data
import app.k9mail.feature.account.oauth.domain.DomainContract
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationState
class AuthorizationStateRepository : DomainContract.AuthorizationStateRepository {
override suspend fun isAuthorized(authorizationState: AuthorizationState): Boolean {
val authState = authorizationState.toAuthState()
return authState.isAuthorized
}
}

View file

@ -1,7 +1,12 @@
package app.k9mail.feature.account.oauth.domain
import android.content.Intent
import app.k9mail.core.common.oauth.OAuthConfiguration
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationIntentResult
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationResult
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationResponse
interface DomainContract {
@ -13,6 +18,14 @@ interface DomainContract {
fun interface GetOAuthRequestIntent {
fun execute(hostname: String, emailAddress: String): AuthorizationIntentResult
}
fun interface FinishOAuthSignIn {
suspend fun execute(authorizationState: AuthorizationState, intent: Intent): AuthorizationResult
}
fun interface CheckIsGoogleSignIn {
fun execute(hostname: String): Boolean
}
}
interface AuthorizationRepository {
@ -20,5 +33,17 @@ interface DomainContract {
configuration: OAuthConfiguration,
emailAddress: String,
): AuthorizationIntentResult
suspend fun getAuthorizationResponse(intent: Intent): AuthorizationResponse?
suspend fun getAuthorizationException(intent: Intent): AuthorizationException?
suspend fun getExchangeToken(
authorizationState: AuthorizationState,
response: AuthorizationResponse,
): AuthorizationResult
}
interface AuthorizationStateRepository {
suspend fun isAuthorized(authorizationState: AuthorizationState): Boolean
}
}

View file

@ -0,0 +1,16 @@
package app.k9mail.feature.account.oauth.domain.entity
sealed interface AuthorizationResult {
data class Success(
val state: AuthorizationState,
) : AuthorizationResult
data class Failure(
val error: Exception,
) : AuthorizationResult
object BrowserNotAvailable : AuthorizationResult
object Canceled : AuthorizationResult
}

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,23 @@
package app.k9mail.feature.account.oauth.domain.usecase
import app.k9mail.feature.account.oauth.domain.DomainContract.UseCase
internal class CheckIsGoogleSignIn : UseCase.CheckIsGoogleSignIn {
override fun execute(hostname: String): Boolean {
for (domain in domainList) {
if (hostname.lowercase().endsWith(domain)) {
return true
}
}
return false
}
private companion object {
val domainList = listOf(
".gmail.com",
".googlemail.com",
".google.com",
)
}
}

View file

@ -0,0 +1,24 @@
package app.k9mail.feature.account.oauth.domain.usecase
import android.content.Intent
import app.k9mail.feature.account.oauth.domain.DomainContract
import app.k9mail.feature.account.oauth.domain.DomainContract.UseCase
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationResult
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationState
class FinishOAuthSignIn(
private val repository: DomainContract.AuthorizationRepository,
) : UseCase.FinishOAuthSignIn {
override suspend fun execute(authorizationState: AuthorizationState, intent: Intent): AuthorizationResult {
val response = repository.getAuthorizationResponse(intent)
val exception = repository.getAuthorizationException(intent)
return if (response != null) {
repository.getExchangeToken(authorizationState, response)
} else if (exception != null) {
AuthorizationResult.Failure(exception)
} else {
AuthorizationResult.Canceled
}
}
}

View file

@ -0,0 +1,87 @@
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) },
isGoogleSignIn = state.isGoogleSignIn,
)
}
}
}
}
@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,55 @@
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 {
data class OnOAuthResult(
val resultCode: Int,
val data: Intent?,
) : Event
object SignInClicked : 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 Canceled : Error
object BrowserNotAvailable : Error
data class Unknown(val error: Exception) : Error
}
}

View file

@ -0,0 +1,64 @@
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(
onOAuthResult: (OAuthResult) -> Unit,
viewModel: ViewModel,
modifier: Modifier = Modifier,
) {
val oAuthLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
) {
viewModel.event(Event.OnOAuthResult(it.resultCode, it.data))
}
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 = { },
onBackClick = { dispatch(Event.OnBackClicked) },
)
},
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 android.content.res.Resources
import app.k9mail.feature.account.oauth.R
import app.k9mail.feature.account.oauth.ui.AccountOAuthContract.Error
internal fun Error.toResourceString(resources: Resources): String {
return when (this) {
Error.BrowserNotAvailable -> resources.getString(R.string.account_oauth_error_browser_not_available)
Error.Canceled -> resources.getString(R.string.account_oauth_error_canceled)
Error.NotSupported -> resources.getString(R.string.account_oauth_error_not_supported)
is Error.Unknown -> resources.getString(R.string.account_oauth_error_failed, error.message)
}
}

View file

@ -0,0 +1,120 @@
package app.k9mail.feature.account.oauth.ui
import android.app.Activity
import android.content.Intent
import androidx.lifecycle.viewModelScope
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
import app.k9mail.feature.account.oauth.domain.DomainContract.UseCase
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationIntentResult
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationResult
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
import kotlinx.coroutines.launch
class AccountOAuthViewModel(
initialState: State = State(),
private val getOAuthRequestIntent: UseCase.GetOAuthRequestIntent,
private val finishOAuthSignIn: UseCase.FinishOAuthSignIn,
private val checkIsGoogleSignIn: UseCase.CheckIsGoogleSignIn,
) : BaseViewModel<State, Event, Effect>(initialState), ViewModel {
override fun initState(state: State) {
val isGoogleSignIn = checkIsGoogleSignIn.execute(state.hostname)
updateState {
state.copy(
isGoogleSignIn = isGoogleSignIn,
)
}
}
override fun event(event: Event) {
when (event) {
is Event.OnOAuthResult -> onOAuthResult(event.resultCode, event.data)
Event.SignInClicked -> onSignIn()
Event.OnBackClicked -> navigateBack()
Event.OnRetryClicked -> onRetry()
}
}
private fun onSignIn() {
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 onRetry() {
updateState { state ->
state.copy(
error = null,
)
}
onSignIn()
}
private fun onOAuthResult(resultCode: Int, data: Intent?) {
if (resultCode == Activity.RESULT_OK && data != null) {
finishSignIn(data)
} else {
updateState { state ->
state.copy(error = Error.Canceled)
}
}
}
private fun finishSignIn(data: Intent) {
updateState { state ->
state.copy(
isLoading = true,
)
}
viewModelScope.launch {
when (val result = finishOAuthSignIn.execute(state.value.authorizationState, data)) {
AuthorizationResult.BrowserNotAvailable -> updateErrorState(Error.BrowserNotAvailable)
AuthorizationResult.Canceled -> updateErrorState(Error.Canceled)
is AuthorizationResult.Failure -> updateErrorState(Error.Unknown(result.error))
is AuthorizationResult.Success -> {
updateState { state ->
state.copy(
authorizationState = result.state,
isLoading = false,
)
}
navigateNext()
}
}
}
}
private fun updateErrorState(error: Error) = updateState { state ->
state.copy(
error = error,
isLoading = false,
)
}
private fun navigateBack() = emitEffect(Effect.NavigateBack)
private fun navigateNext() = emitEffect(Effect.NavigateNext(state.value.authorizationState))
}

View file

@ -0,0 +1,25 @@
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

@ -0,0 +1,74 @@
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.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,
) {
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,
)
if (isGoogleSignIn) {
SignInWithGoogleButton(
onClick = onSignInClick,
)
} else {
Button(
text = stringResource(id = R.string.account_oauth_sign_in_button),
onClick = onSignInClick,
)
}
}
}
@DevicePreviews
@Composable
internal fun SignInViewPreview() {
SignInView(
emailAddress = "test@example.com",
onSignInClick = {},
isGoogleSignIn = false,
)
}
@DevicePreviews
@Composable
internal fun SignInViewWithGooglePreview() {
SignInView(
emailAddress = "test@gmail.com",
onSignInClick = {},
isGoogleSignIn = true,
)
}

View file

@ -1,5 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<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>
<string name="account_oauth_error_canceled">Authorization canceled</string>
<string name="account_oauth_error_failed">Authorization failed with the following error: <xliff:g id="error">%s</xliff:g></string>
<string name="account_oauth_error_not_supported">OAuth 2.0 is currently not supported with this provider.</string>
<string name="account_oauth_error_browser_not_available">The app couldn\'t find a browser to use for granting access to your account.</string>
</resources>

View file

@ -1,22 +1,34 @@
package app.k9mail.feature.account.oauth.data
import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import app.k9mail.core.common.oauth.OAuthConfiguration
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationIntentResult
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationResult
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationState
import assertk.all
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import assertk.assertions.prop
import kotlinx.coroutines.test.runTest
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationService.TokenResponseCallback
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.GrantTypeValues
import net.openid.appauth.ResponseTypeValues
import net.openid.appauth.TokenRequest
import net.openid.appauth.TokenResponse
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.mock
import org.mockito.kotlin.stub
import org.robolectric.RobolectricTestRunner
@ -27,43 +39,200 @@ class AuthorizationRepositoryTest {
private val service: AuthorizationService = mock<AuthorizationService>()
@Test
fun `should return Success with intent when hostname has oauth configuration`() = runTest {
fun `getAuthorizationRequestIntent should return Success with intent when hostname has oauth configuration`() =
runTest {
val testSubject = AuthorizationRepository(
service = service,
)
val emailAddress = "emailAddress"
val intent = Intent()
val authRequestCapture = argumentCaptor<AuthorizationRequest>().apply {
service.stub { on { getAuthorizationRequestIntent(capture()) }.thenReturn(intent) }
}
// When
val result = testSubject.getAuthorizationRequestIntent(oAuthConfiguration, emailAddress)
// Then
assertThat(result).isEqualTo(
AuthorizationIntentResult.Success(
intent = intent,
),
)
assertThat(authRequestCapture.firstValue).all {
prop(AuthorizationRequest::configuration).all {
prop(AuthorizationServiceConfiguration::authorizationEndpoint).isEqualTo(
oAuthConfiguration.authorizationEndpoint.toUri(),
)
prop(AuthorizationServiceConfiguration::tokenEndpoint).isEqualTo(
oAuthConfiguration.tokenEndpoint.toUri(),
)
}
prop(AuthorizationRequest::clientId).isEqualTo(oAuthConfiguration.clientId)
prop(AuthorizationRequest::responseType).isEqualTo(ResponseTypeValues.CODE)
prop(AuthorizationRequest::redirectUri).isEqualTo(oAuthConfiguration.redirectUri.toUri())
prop(AuthorizationRequest::scope).isEqualTo("scope scope2")
prop(AuthorizationRequest::codeVerifier).isNull()
prop(AuthorizationRequest::loginHint).isEqualTo(emailAddress)
}
}
@Test
fun `getAuthorizationResponse should return AuthorizationResponse when intent invalid`() = runTest {
val testSubject = AuthorizationRepository(
service = service,
)
val emailAddress = "emailAddress"
val intent = Intent()
val authRequestCapture = argumentCaptor<AuthorizationRequest>().apply {
service.stub { on { getAuthorizationRequestIntent(capture()) }.thenReturn(intent) }
val intent = Intent().also {
it.putExtra(AuthorizationResponse.EXTRA_RESPONSE, authorizationResponse.jsonSerializeString())
}
// When
val result = testSubject.getAuthorizationRequestIntent(oAuthConfiguration, emailAddress)
val result = testSubject.getAuthorizationResponse(intent)
// Then
assertThat(result).isEqualTo(
AuthorizationIntentResult.Success(
intent = intent,
),
)
assertThat(authRequestCapture.firstValue).all {
prop(AuthorizationRequest::configuration).all {
prop(AuthorizationServiceConfiguration::authorizationEndpoint).isEqualTo(
oAuthConfiguration.authorizationEndpoint.toUri(),
)
prop(AuthorizationServiceConfiguration::tokenEndpoint).isEqualTo(
oAuthConfiguration.tokenEndpoint.toUri(),
)
assertThat(result).isNotNull().all {
prop(AuthorizationResponse::request).all {
prop(AuthorizationRequest::configuration).all {
prop(AuthorizationServiceConfiguration::authorizationEndpoint).isEqualTo(
authorizationConfiguration.authorizationEndpoint,
)
prop(AuthorizationServiceConfiguration::tokenEndpoint).isEqualTo(
authorizationConfiguration.tokenEndpoint,
)
}
prop(AuthorizationRequest::clientId).isEqualTo(authorizationRequest.clientId)
prop(AuthorizationRequest::responseType).isEqualTo(authorizationRequest.responseType)
prop(AuthorizationRequest::redirectUri).isEqualTo(authorizationRequest.redirectUri)
}
prop(AuthorizationRequest::clientId).isEqualTo(oAuthConfiguration.clientId)
prop(AuthorizationRequest::responseType).isEqualTo(ResponseTypeValues.CODE)
prop(AuthorizationRequest::redirectUri).isEqualTo(oAuthConfiguration.redirectUri.toUri())
prop(AuthorizationRequest::scope).isEqualTo("scope scope2")
prop(AuthorizationRequest::codeVerifier).isNull()
prop(AuthorizationRequest::loginHint).isEqualTo(emailAddress)
}
}
@Test
fun `getAuthorizationResponse should return null when intent is invalid`() = runTest {
val testSubject = AuthorizationRepository(
service = service,
)
val intent = Intent()
// When
val result = testSubject.getAuthorizationResponse(intent)
// Then
assertThat(result).isNull()
}
@Test
fun `getAuthorizationException should return AuthorizationException when intent is valid`() = runTest {
val testSubject = AuthorizationRepository(
service = service,
)
val authorizationException = AuthorizationException(
AuthorizationException.TYPE_OAUTH_AUTHORIZATION_ERROR,
1,
"error",
"errorDescription",
Uri.parse("https://example.com/errorUri"),
null,
)
val intent = Intent().also {
it.putExtra(AuthorizationException.EXTRA_EXCEPTION, authorizationException.toJsonString())
}
// When
val result = testSubject.getAuthorizationException(intent)
// Then
assertThat(result).isEqualTo(authorizationException)
}
@Test
fun `getAuthorizationException should return null when intent is invalid`() = runTest {
val testSubject = AuthorizationRepository(
service = service,
)
val intent = Intent()
// When
val result = testSubject.getAuthorizationException(intent)
// Then
assertThat(result).isNull()
}
@Test
fun `getExchangeToken should return success when tokenRequest successful`() = runTest {
val testSubject = AuthorizationRepository(
service = service,
)
val tokenRequest = TokenRequest.Builder(
authorizationConfiguration,
authorizationRequest.clientId,
).setGrantType(GrantTypeValues.AUTHORIZATION_CODE)
.setAuthorizationCode("authorizationCode")
.setRedirectUri(authorizationRequest.redirectUri)
.build()
val tokenResponse = TokenResponse.Builder(tokenRequest)
.build()
service.stub {
on { performTokenRequest(any(), any()) } doAnswer {
val callback = it.getArgument(1, TokenResponseCallback::class.java)
callback.onTokenRequestCompleted(tokenResponse, null)
}
}
val authorizationState = AuthorizationState()
val result = testSubject.getExchangeToken(authorizationState, authorizationResponse)
val authState = authorizationState.toAuthState()
authState.update(tokenResponse, null)
val successAuthorizationState = authState.toAuthorizationState()
assertThat(result).isEqualTo(AuthorizationResult.Success(successAuthorizationState))
}
fun `getExchangeToken should return failure when tokenRequest failure`() = runTest {
val testSubject = AuthorizationRepository(
service = service,
)
val authorizationException = AuthorizationException(
AuthorizationException.TYPE_OAUTH_AUTHORIZATION_ERROR,
1,
"error",
"errorDescription",
Uri.parse("https://example.com/errorUri"),
null,
)
service.stub {
on { performTokenRequest(any(), any()) } doAnswer {
val callback = it.getArgument(1, TokenResponseCallback::class.java)
callback.onTokenRequestCompleted(null, authorizationException)
}
}
val authorizationState = AuthorizationState()
val result = testSubject.getExchangeToken(authorizationState, authorizationResponse)
assertThat(result).isEqualTo(AuthorizationResult.Failure(authorizationException))
}
fun `getExchangeToken should return unknown failure when tokenRequest null for response and exception`() = runTest {
val testSubject = AuthorizationRepository(
service = service,
)
val exception = Exception("Unknown error")
service.stub {
on { performTokenRequest(any(), any()) } doAnswer {
val callback = it.getArgument(1, TokenResponseCallback::class.java)
callback.onTokenRequestCompleted(null, null)
}
}
val authorizationState = AuthorizationState()
val result = testSubject.getExchangeToken(authorizationState, authorizationResponse)
assertThat(result).isEqualTo(AuthorizationResult.Failure(exception))
}
private companion object {
val oAuthConfiguration = OAuthConfiguration(
clientId = "clientId",
@ -72,5 +241,21 @@ class AuthorizationRepositoryTest {
tokenEndpoint = "token.example.com",
redirectUri = "redirect.example.com",
)
val authorizationConfiguration = AuthorizationServiceConfiguration(
Uri.parse("https://example.com/authorize"),
Uri.parse("https://example.com/token"),
)
val authorizationRequest = AuthorizationRequest.Builder(
authorizationConfiguration,
"clientId",
"responseType",
Uri.parse("https://example.com/redirectUri"),
).build()
val authorizationResponse = AuthorizationResponse.Builder(authorizationRequest)
.setAuthorizationCode("authorizationCode")
.build()
}
}

View file

@ -0,0 +1,54 @@
package app.k9mail.feature.account.oauth.data
import android.net.Uri
import assertk.assertThat
import assertk.assertions.isFalse
import assertk.assertions.isTrue
import kotlinx.coroutines.test.runTest
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.TokenRequest
import net.openid.appauth.TokenResponse
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class AuthorizationStateRepositoryTest {
@Test
fun `should return false with unauthorized auth state`() = runTest {
val authState = AuthState()
val authorizationState = authState.toAuthorizationState()
val testSubject = AuthorizationStateRepository()
val result = testSubject.isAuthorized(authorizationState)
assertThat(result).isFalse()
}
@Test
fun `should return true with authorized auth state`() = runTest {
val authState = AuthState()
val clientId = "clientId"
val configuration = AuthorizationServiceConfiguration(
Uri.parse("https://example.com"),
Uri.parse("https://example.com"),
)
val tokenRequest = TokenRequest.Builder(configuration, clientId)
.setGrantType(TokenRequest.GRANT_TYPE_PASSWORD)
.build()
val tokenResponse = TokenResponse.Builder(tokenRequest)
.setAccessToken("accessToken")
.setIdToken("idToken")
.build()
authState.update(tokenResponse, null)
val authorizationState = authState.toAuthorizationState()
val testSubject = AuthorizationStateRepository()
val result = testSubject.isAuthorized(authorizationState)
assertThat(result).isTrue()
}
}

View file

@ -0,0 +1,54 @@
package app.k9mail.feature.account.oauth.domain
import android.content.Intent
import app.k9mail.core.common.oauth.OAuthConfiguration
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationIntentResult
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationResult
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationResponse
class FakeAuthorizationRepository(
private val answerGetAuthorizationRequestIntent: AuthorizationIntentResult = AuthorizationIntentResult.NotSupported,
private val answerGetAuthorizationResponse: AuthorizationResponse? = null,
private val answerGetAuthorizationException: AuthorizationException? = null,
private val answerGetExchangeToken: AuthorizationResult = AuthorizationResult.Canceled,
) : DomainContract.AuthorizationRepository {
var recordedGetAuthorizationRequestIntentConfiguration: OAuthConfiguration? = null
var recordedGetAuthorizationRequestIntentEmailAddress: String? = null
override fun getAuthorizationRequestIntent(
configuration: OAuthConfiguration,
emailAddress: String,
): AuthorizationIntentResult {
recordedGetAuthorizationRequestIntentConfiguration = configuration
recordedGetAuthorizationRequestIntentEmailAddress = emailAddress
return answerGetAuthorizationRequestIntent
}
var recordedGetAuthorizationResponseIntent: Intent? = null
override suspend fun getAuthorizationResponse(intent: Intent): AuthorizationResponse? {
recordedGetAuthorizationResponseIntent = intent
return answerGetAuthorizationResponse
}
var recordedGetAuthorizationExceptionIntent: Intent? = null
override suspend fun getAuthorizationException(intent: Intent): AuthorizationException? {
recordedGetAuthorizationExceptionIntent = intent
return answerGetAuthorizationException
}
var recordedGetExchangeTokenAuthorizationState: AuthorizationState? = null
var recordedGetExchangeTokenResponse: AuthorizationResponse? = null
override suspend fun getExchangeToken(
authorizationState: AuthorizationState,
response: AuthorizationResponse,
): AuthorizationResult {
recordedGetExchangeTokenAuthorizationState = authorizationState
recordedGetExchangeTokenResponse = response
return answerGetExchangeToken
}
}

View file

@ -0,0 +1,37 @@
package app.k9mail.feature.account.oauth.domain.usecase
import assertk.assertThat
import assertk.assertions.isFalse
import assertk.assertions.isTrue
import org.junit.Test
class CheckIsGoogleSignInTest {
private val testSubject = CheckIsGoogleSignIn()
@Test
fun `should return true when hostname ends with a google domain`() {
val hostnames = listOf(
"mail.gmail.com",
"mail.googlemail.com",
"mail.google.com",
)
for (hostname in hostnames) {
assertThat(testSubject.execute(hostname)).isTrue()
}
}
@Test
fun `should return false when hostname does not end with a google domain`() {
val hostnames = listOf(
"mail.example.com",
"mail.example.org",
"mail.example.net",
)
for (hostname in hostnames) {
assertThat(testSubject.execute(hostname)).isFalse()
}
}
}

View file

@ -0,0 +1,103 @@
package app.k9mail.feature.account.oauth.domain.usecase
import android.content.Intent
import app.k9mail.feature.account.oauth.domain.FakeAuthorizationRepository
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationResult
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationState
import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlinx.coroutines.test.runTest
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationResponse
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.mock
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class FinishOAuthSignInTest {
@Test
fun `should return failure when intent has encoded exception`() = runTest {
val authorizationState = AuthorizationState()
val intent = Intent()
val exception = AuthorizationException(
AuthorizationException.TYPE_GENERAL_ERROR,
1,
"error",
"error_description",
null,
null,
)
val repository = FakeAuthorizationRepository(
answerGetAuthorizationException = exception,
)
val testSubject = FinishOAuthSignIn(
repository = repository,
)
val result = testSubject.execute(
authorizationState = authorizationState,
intent = intent,
)
assertThat(result).isEqualTo(AuthorizationResult.Failure(exception))
assertThat(repository.recordedGetAuthorizationExceptionIntent).isEqualTo(intent)
}
@Test
fun `should return canceled when intent has no response and no exception`() = runTest {
val authorizationState = AuthorizationState()
val intent = Intent()
val exception = AuthorizationException(
AuthorizationException.TYPE_GENERAL_ERROR,
1,
"error",
"error_description",
null,
null,
)
val repository = FakeAuthorizationRepository(
answerGetAuthorizationResponse = null,
answerGetAuthorizationException = null,
)
val testSubject = FinishOAuthSignIn(
repository = repository,
)
val result = testSubject.execute(
authorizationState = authorizationState,
intent = intent,
)
assertThat(result).isEqualTo(AuthorizationResult.Canceled)
assertThat(repository.recordedGetAuthorizationResponseIntent).isEqualTo(intent)
assertThat(repository.recordedGetAuthorizationExceptionIntent).isEqualTo(intent)
}
@Test
fun `should return success when intent has response`() = runTest {
val authorizationState = AuthorizationState()
val intent = Intent()
val response = mock<AuthorizationResponse>()
val repository = FakeAuthorizationRepository(
answerGetAuthorizationResponse = response,
answerGetAuthorizationException = null,
answerGetExchangeToken = AuthorizationResult.Success(authorizationState),
)
val testSubject = FinishOAuthSignIn(
repository = repository,
)
val result = testSubject.execute(
authorizationState = authorizationState,
intent = intent,
)
assertThat(result).isEqualTo(AuthorizationResult.Success(authorizationState))
assertThat(repository.recordedGetAuthorizationResponseIntent).isEqualTo(intent)
assertThat(repository.recordedGetAuthorizationExceptionIntent).isEqualTo(intent)
assertThat(repository.recordedGetExchangeTokenAuthorizationState).isEqualTo(authorizationState)
assertThat(repository.recordedGetExchangeTokenResponse).isEqualTo(response)
}
}

View file

@ -2,7 +2,7 @@ package app.k9mail.feature.account.oauth.domain.usecase
import android.content.Intent
import app.k9mail.core.common.oauth.OAuthConfiguration
import app.k9mail.feature.account.oauth.domain.DomainContract
import app.k9mail.feature.account.oauth.domain.FakeAuthorizationRepository
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationIntentResult
import assertk.assertThat
import assertk.assertions.isEqualTo
@ -28,7 +28,9 @@ class GetOAuthRequestIntentTest {
@Test
fun `should return Success when repository has intent`() = runTest {
val intent = Intent()
val repository = FakeAuthorizationRepository(intent)
val repository = FakeAuthorizationRepository(
answerGetAuthorizationRequestIntent = AuthorizationIntentResult.Success(intent),
)
val testSubject = GetOAuthRequestIntent(
repository = repository,
configurationProvider = { oAuthConfiguration },
@ -43,8 +45,8 @@ class GetOAuthRequestIntentTest {
intent = intent,
),
)
assertThat(repository.recordedConfiguration).isEqualTo(oAuthConfiguration)
assertThat(repository.recordedEmailAddress).isEqualTo(emailAddress)
assertThat(repository.recordedGetAuthorizationRequestIntentConfiguration).isEqualTo(oAuthConfiguration)
assertThat(repository.recordedGetAuthorizationRequestIntentEmailAddress).isEqualTo(emailAddress)
}
private companion object {
@ -56,21 +58,4 @@ class GetOAuthRequestIntentTest {
redirectUri = "redirect.example.com",
)
}
private class FakeAuthorizationRepository(
private val intent: Intent = Intent(),
) : DomainContract.AuthorizationRepository {
var recordedConfiguration: OAuthConfiguration? = null
var recordedEmailAddress: String? = null
override fun getAuthorizationRequestIntent(
configuration: OAuthConfiguration,
emailAddress: String,
): AuthorizationIntentResult {
recordedConfiguration = configuration
recordedEmailAddress = emailAddress
return AuthorizationIntentResult.Success(intent)
}
}
}

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,384 @@
package app.k9mail.feature.account.oauth.ui
import android.app.Activity
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.domain.entity.AuthorizationResult
import app.k9mail.feature.account.oauth.domain.entity.AuthorizationState
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.delay
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()
@Test
fun `should change state when google hostname found on initState`() = runTest {
val testSubject = createTestSubject(
isGoogleSignIn = true,
)
val stateTurbine = testSubject.state.testIn(backgroundScope)
val effectTurbine = testSubject.effect.testIn(backgroundScope)
val turbines = listOf(stateTurbine, effectTurbine)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(State())
}
testSubject.initState(defaultState)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(defaultState.copy(isGoogleSignIn = true))
}
}
@Test
fun `should not change state when no google hostname found on initState`() = runTest {
val testSubject = createTestSubject(
isGoogleSignIn = false,
)
val stateTurbine = testSubject.state.testIn(backgroundScope)
val effectTurbine = testSubject.effect.testIn(backgroundScope)
val turbines = listOf(stateTurbine, effectTurbine)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(State())
}
testSubject.initState(defaultState)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(defaultState.copy(isGoogleSignIn = false))
}
}
@Test
fun `should launch OAuth when SignInClicked event received`() = runTest {
val initialState = defaultState
val testSubject = createTestSubject(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 = defaultState
val testSubject = createTestSubject(
authorizationIntentResult = 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 = defaultState.copy(
error = Error.NotSupported,
)
val testSubject = createTestSubject(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 finish OAuth sign in when onOAuthResult received with success`() = runTest {
val initialState = defaultState
val authorizationState = AuthorizationState(state = "state")
val testSubject = createTestSubject(
authorizationResult = AuthorizationResult.Success(authorizationState),
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.OnOAuthResult(resultCode = Activity.RESULT_OK, data = intent))
val loadingState = initialState.copy(isLoading = true)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(loadingState)
}
val successState = loadingState.copy(
isLoading = false,
authorizationState = authorizationState,
)
assertThat(stateTurbine.awaitItem()).isEqualTo(successState)
assertThatAndTurbinesConsumed(
actual = effectTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(Effect.NavigateNext(authorizationState))
}
}
@Test
fun `should set error state when onOAuthResult received with canceled`() = runTest {
val initialState = defaultState
val testSubject = createTestSubject(
authorizationResult = AuthorizationResult.Canceled,
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.OnOAuthResult(resultCode = Activity.RESULT_CANCELED, data = intent))
val failureState = initialState.copy(
error = Error.Canceled,
)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(failureState)
}
}
@Test
fun `should finish OAuth sign in when onOAuthResult received with success but authorization result is cancelled`() =
runTest {
val initialState = defaultState
val testSubject = createTestSubject(
authorizationResult = AuthorizationResult.Canceled,
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.OnOAuthResult(resultCode = Activity.RESULT_OK, data = intent))
val loadingState = initialState.copy(isLoading = true)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(loadingState)
}
val failureState = loadingState.copy(
isLoading = false,
error = Error.Canceled,
)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(failureState)
}
}
@Test
fun `should finish OAuth sign in when onOAuthResult received with success but authorization result is failure`() =
runTest {
val initialState = defaultState
val failure = Exception("failure")
val testSubject = createTestSubject(
authorizationResult = AuthorizationResult.Failure(failure),
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.OnOAuthResult(resultCode = Activity.RESULT_OK, data = intent))
val loadingState = initialState.copy(isLoading = true)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(loadingState)
}
val failureState = loadingState.copy(
isLoading = false,
error = Error.Unknown(failure),
)
assertThatAndTurbinesConsumed(
actual = stateTurbine.awaitItem(),
turbines = turbines,
) {
isEqualTo(failureState)
}
}
@Test
fun `should emit NavigateBack effect when OnBackClicked event received`() = runTest {
val viewModel = createTestSubject()
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)
}
}
private companion object {
val defaultState = State(
hostname = "example.com",
emailAddress = "test@example.com",
)
val intent = Intent()
fun createTestSubject(
authorizationIntentResult: AuthorizationIntentResult = AuthorizationIntentResult.Success(intent = intent),
authorizationResult: AuthorizationResult = AuthorizationResult.Success(AuthorizationState()),
isGoogleSignIn: Boolean = false,
initialState: State = State(),
) = AccountOAuthViewModel(
getOAuthRequestIntent = { _, _ ->
authorizationIntentResult
},
finishOAuthSignIn = { _, _ ->
delay(50)
authorizationResult
},
checkIsGoogleSignIn = { _ ->
isGoogleSignIn
},
initialState = initialState,
)
}
}

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