Merge pull request #7079 from thundernest/add_account_setup_oauth
Add account setup oauth
This commit is contained in:
commit
0c0e57f7ba
31 changed files with 1685 additions and 47 deletions
|
@ -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") ||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
package app.k9mail.feature.account.oauth.domain.entity
|
||||
|
||||
data class AuthorizationState(
|
||||
val state: String? = null,
|
||||
)
|
|
@ -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
|
||||
}
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue