mirror of
https://github.com/bitwarden/android.git
synced 2024-11-22 09:25:58 +03:00
BIT-320: Loading and error dialogs (#101)
This commit is contained in:
parent
3d925a7804
commit
c7ab805f91
14 changed files with 434 additions and 87 deletions
|
@ -26,4 +26,23 @@ sealed class GetTokenResponseJson {
|
||||||
@SerialName("HCaptcha_SiteKey")
|
@SerialName("HCaptcha_SiteKey")
|
||||||
val captchaKey: String,
|
val captchaKey: String,
|
||||||
) : GetTokenResponseJson()
|
) : GetTokenResponseJson()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Models json body of an invalid request.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class Invalid(
|
||||||
|
@SerialName("ErrorModel")
|
||||||
|
val errorModel: ErrorModel,
|
||||||
|
) : GetTokenResponseJson() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error body of an invalid request containing a message.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ErrorModel(
|
||||||
|
@SerialName("Message")
|
||||||
|
val errorMessage: String,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,8 +2,6 @@ package com.x8bit.bitwarden.data.auth.datasource.network.model
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Models result of logging in.
|
* Models result of logging in.
|
||||||
*
|
|
||||||
* TODO: Add more detail to these cases to expose server error messages (BIT-320)
|
|
||||||
*/
|
*/
|
||||||
sealed class LoginResult {
|
sealed class LoginResult {
|
||||||
|
|
||||||
|
@ -20,5 +18,5 @@ sealed class LoginResult {
|
||||||
/**
|
/**
|
||||||
* There was an error logging in.
|
* There was an error logging in.
|
||||||
*/
|
*/
|
||||||
data object Error : LoginResult()
|
data class Error(val errorMessage: String?) : LoginResult()
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,8 +2,9 @@ package com.x8bit.bitwarden.data.auth.datasource.network.service
|
||||||
|
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.network.api.IdentityApi
|
import com.x8bit.bitwarden.data.auth.datasource.network.api.IdentityApi
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
|
||||||
|
import com.x8bit.bitwarden.data.platform.datasource.network.model.toBitwardenError
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlEncode
|
import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlEncode
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.network.util.parseErrorBodyAsResult
|
import com.x8bit.bitwarden.data.platform.datasource.network.util.parseErrorBodyOrNull
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.net.HttpURLConnection.HTTP_BAD_REQUEST
|
import java.net.HttpURLConnection.HTTP_BAD_REQUEST
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
@ -36,13 +37,14 @@ class IdentityServiceImpl constructor(
|
||||||
email = email,
|
email = email,
|
||||||
captchaResponse = captchaToken,
|
captchaResponse = captchaToken,
|
||||||
)
|
)
|
||||||
.fold(
|
.recoverCatching { throwable ->
|
||||||
onSuccess = { Result.success(it) },
|
val bitwardenError = throwable.toBitwardenError()
|
||||||
onFailure = {
|
bitwardenError.parseErrorBodyOrNull<GetTokenResponseJson.CaptchaRequired>(
|
||||||
it.parseErrorBodyAsResult<GetTokenResponseJson.CaptchaRequired>(
|
|
||||||
code = HTTP_BAD_REQUEST,
|
code = HTTP_BAD_REQUEST,
|
||||||
json = json,
|
json = json,
|
||||||
)
|
) ?: bitwardenError.parseErrorBodyOrNull<GetTokenResponseJson.Invalid>(
|
||||||
},
|
code = HTTP_BAD_REQUEST,
|
||||||
)
|
json = json,
|
||||||
|
) ?: throw throwable
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.auth.repository
|
||||||
import com.bitwarden.core.Kdf
|
import com.bitwarden.core.Kdf
|
||||||
import com.bitwarden.sdk.Client
|
import com.bitwarden.sdk.Client
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.CaptchaRequired
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.CaptchaRequired
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.Success
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.Success
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
|
||||||
|
@ -61,10 +62,7 @@ class AuthRepositoryImpl @Inject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.fold(
|
.fold(
|
||||||
onFailure = {
|
onFailure = { LoginResult.Error(errorMessage = null) },
|
||||||
// TODO: Add more detail to error case to expose server error messages (BIT-320)
|
|
||||||
LoginResult.Error
|
|
||||||
},
|
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
when (it) {
|
when (it) {
|
||||||
is CaptchaRequired -> LoginResult.CaptchaRequired(it.captchaKey)
|
is CaptchaRequired -> LoginResult.CaptchaRequired(it.captchaKey)
|
||||||
|
@ -75,6 +73,10 @@ class AuthRepositoryImpl @Inject constructor(
|
||||||
mutableAuthStateFlow.value = AuthState.Authenticated(it.accessToken)
|
mutableAuthStateFlow.value = AuthState.Authenticated(it.accessToken)
|
||||||
LoginResult.Success
|
LoginResult.Success
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is GetTokenResponseJson.Invalid -> {
|
||||||
|
LoginResult.Error(errorMessage = it.errorModel.errorMessage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
package com.x8bit.bitwarden.data.platform.datasource.network.model
|
||||||
|
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents different types of errors that can occur in the Bitwarden application.
|
||||||
|
*/
|
||||||
|
sealed class BitwardenError {
|
||||||
|
/**
|
||||||
|
* An abstract property that holds the underlying throwable that caused the error.
|
||||||
|
*/
|
||||||
|
abstract val throwable: Throwable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Errors related to HTTP requests and responses.
|
||||||
|
*/
|
||||||
|
data class Http(override val throwable: HttpException) : BitwardenError() {
|
||||||
|
/**
|
||||||
|
* The error code of the HTTP response.
|
||||||
|
*/
|
||||||
|
val code: Int get() = throwable.code()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The response body of the HTTP response.
|
||||||
|
*/
|
||||||
|
val responseBodyString: String? by lazy {
|
||||||
|
throwable.response()?.errorBody()?.string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Errors related to network.
|
||||||
|
*/
|
||||||
|
data class Network(override val throwable: IOException) : BitwardenError()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Other types of errors not covered by any special cases.
|
||||||
|
*/
|
||||||
|
data class Other(override val throwable: Throwable) : BitwardenError()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a [Throwable] into a [BitwardenError].
|
||||||
|
*/
|
||||||
|
fun Throwable.toBitwardenError(): BitwardenError {
|
||||||
|
return when (this) {
|
||||||
|
is IOException -> BitwardenError.Network(this)
|
||||||
|
is HttpException -> BitwardenError.Http(this)
|
||||||
|
else -> BitwardenError.Other(this)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,8 +1,7 @@
|
||||||
package com.x8bit.bitwarden.data.platform.datasource.network.util
|
package com.x8bit.bitwarden.data.platform.datasource.network.util
|
||||||
|
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import com.x8bit.bitwarden.data.platform.datasource.network.model.BitwardenError
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.decodeFromStream
|
|
||||||
import retrofit2.HttpException
|
import retrofit2.HttpException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -10,23 +9,21 @@ import retrofit2.HttpException
|
||||||
*
|
*
|
||||||
* Useful in service layer for parsing non-200 response bodies.
|
* Useful in service layer for parsing non-200 response bodies.
|
||||||
*
|
*
|
||||||
* If the receiver is not an [HttpException] or the error body cannot be parsed, the original
|
* If the receiver is not an [HttpException] or the error body cannot be parsed, null will be
|
||||||
* Throwable will be returned as a Result.failure.
|
* returned.
|
||||||
*
|
*
|
||||||
* @param code HTTP code associated with the error. Only responses with this code will be attempted
|
* @param code HTTP code associated with the error. Only responses with this code will be attempted
|
||||||
* to be parsed.
|
* to be parsed.
|
||||||
* @param json [Json] serializer to use.
|
* @param json [Json] serializer to use.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
inline fun <reified T> BitwardenError.parseErrorBodyOrNull(code: Int, json: Json): T? =
|
||||||
inline fun <reified T> Throwable.parseErrorBodyAsResult(code: Int, json: Json): Result<T> =
|
(this as? BitwardenError.Http)
|
||||||
(this as? HttpException)
|
?.takeIf { it.code == code }
|
||||||
?.response()
|
?.responseBodyString
|
||||||
?.takeIf { it.code() == code }
|
?.let { responseBody ->
|
||||||
?.errorBody()
|
|
||||||
?.let { errorBody ->
|
|
||||||
try {
|
try {
|
||||||
Result.success(json.decodeFromStream(errorBody.byteStream()))
|
json.decodeFromString(responseBody)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
Result.failure(this)
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} ?: Result.failure(this)
|
|
||||||
|
|
|
@ -27,9 +27,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.x8bit.bitwarden.R
|
import com.x8bit.bitwarden.R
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
|
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowTopAppBar
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton
|
||||||
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenOutlinedButtonWithIcon
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenOutlinedButtonWithIcon
|
||||||
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowTopAppBar
|
||||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField
|
import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -48,11 +50,6 @@ fun LoginScreen(
|
||||||
when (event) {
|
when (event) {
|
||||||
LoginEvent.NavigateBack -> onNavigateBack()
|
LoginEvent.NavigateBack -> onNavigateBack()
|
||||||
is LoginEvent.NavigateToCaptcha -> intentHandler.startActivity(intent = event.intent)
|
is LoginEvent.NavigateToCaptcha -> intentHandler.startActivity(intent = event.intent)
|
||||||
is LoginEvent.ShowErrorDialog -> {
|
|
||||||
// TODO Show proper error Dialog
|
|
||||||
Toast.makeText(context, event.messageRes, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
|
|
||||||
is LoginEvent.ShowToast -> {
|
is LoginEvent.ShowToast -> {
|
||||||
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
@ -67,6 +64,13 @@ fun LoginScreen(
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
.verticalScroll(scrollState),
|
.verticalScroll(scrollState),
|
||||||
) {
|
) {
|
||||||
|
BitwardenLoadingDialog(
|
||||||
|
visibilityState = state.loadingDialogState,
|
||||||
|
)
|
||||||
|
BitwardenBasicDialog(
|
||||||
|
visibilityState = state.errorDialogState,
|
||||||
|
onDismissRequest = { viewModel.trySendAction(LoginAction.ErrorDialogDismiss) },
|
||||||
|
)
|
||||||
BitwardenOverflowTopAppBar(
|
BitwardenOverflowTopAppBar(
|
||||||
title = stringResource(id = R.string.app_name),
|
title = stringResource(id = R.string.app_name),
|
||||||
navigationIcon = painterResource(id = R.drawable.ic_close),
|
navigationIcon = painterResource(id = R.drawable.ic_close),
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
|
@file:Suppress("TooManyFunctions")
|
||||||
|
|
||||||
package com.x8bit.bitwarden.ui.auth.feature.login
|
package com.x8bit.bitwarden.ui.auth.feature.login
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.x8bit.bitwarden.R
|
import com.x8bit.bitwarden.R
|
||||||
|
@ -11,6 +12,9 @@ import com.x8bit.bitwarden.data.auth.datasource.network.util.CaptchaCallbackToke
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.network.util.generateIntentForCaptcha
|
import com.x8bit.bitwarden.data.auth.datasource.network.util.generateIntentForCaptcha
|
||||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||||
|
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||||
|
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
|
||||||
|
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
@ -35,6 +39,8 @@ class LoginViewModel @Inject constructor(
|
||||||
isLoginButtonEnabled = true,
|
isLoginButtonEnabled = true,
|
||||||
passwordInput = "",
|
passwordInput = "",
|
||||||
region = LoginArgs(savedStateHandle).regionLabel,
|
region = LoginArgs(savedStateHandle).regionLabel,
|
||||||
|
loadingDialogState = LoadingDialogState.Hidden,
|
||||||
|
errorDialogState = BasicDialogState.Hidden,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
@ -62,16 +68,59 @@ class LoginViewModel @Inject constructor(
|
||||||
LoginAction.NotYouButtonClick -> handleNotYouButtonClicked()
|
LoginAction.NotYouButtonClick -> handleNotYouButtonClicked()
|
||||||
LoginAction.SingleSignOnClick -> handleSingleSignOnClicked()
|
LoginAction.SingleSignOnClick -> handleSingleSignOnClicked()
|
||||||
is LoginAction.PasswordInputChanged -> handlePasswordInputChanged(action)
|
is LoginAction.PasswordInputChanged -> handlePasswordInputChanged(action)
|
||||||
|
is LoginAction.ErrorDialogDismiss -> handleErrorDialogDismiss()
|
||||||
is LoginAction.Internal.ReceiveCaptchaToken -> {
|
is LoginAction.Internal.ReceiveCaptchaToken -> {
|
||||||
handleCaptchaTokenReceived(action.tokenResult)
|
handleCaptchaTokenReceived(action.tokenResult)
|
||||||
}
|
}
|
||||||
|
is LoginAction.Internal.ReceiveLoginResult -> {
|
||||||
|
handleReceiveLoginResult(action = action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleReceiveLoginResult(action: LoginAction.Internal.ReceiveLoginResult) {
|
||||||
|
when (val loginResult = action.loginResult) {
|
||||||
|
is LoginResult.CaptchaRequired -> {
|
||||||
|
mutableStateFlow.update { it.copy(loadingDialogState = LoadingDialogState.Hidden) }
|
||||||
|
sendEvent(
|
||||||
|
event = LoginEvent.NavigateToCaptcha(
|
||||||
|
intent = loginResult.generateIntentForCaptcha(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is LoginResult.Error -> {
|
||||||
|
mutableStateFlow.update {
|
||||||
|
it.copy(
|
||||||
|
errorDialogState = BasicDialogState.Shown(
|
||||||
|
title = R.string.an_error_has_occurred.asText(),
|
||||||
|
message = (loginResult.errorMessage)?.asText()
|
||||||
|
?: R.string.generic_error_message.asText(),
|
||||||
|
),
|
||||||
|
loadingDialogState = LoadingDialogState.Hidden,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is LoginResult.Success -> {
|
||||||
|
mutableStateFlow.update { it.copy(loadingDialogState = LoadingDialogState.Hidden) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleErrorDialogDismiss() {
|
||||||
|
mutableStateFlow.update { it.copy(errorDialogState = BasicDialogState.Hidden) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleCaptchaTokenReceived(tokenResult: CaptchaCallbackTokenResult) {
|
private fun handleCaptchaTokenReceived(tokenResult: CaptchaCallbackTokenResult) {
|
||||||
when (tokenResult) {
|
when (tokenResult) {
|
||||||
CaptchaCallbackTokenResult.MissingToken -> {
|
CaptchaCallbackTokenResult.MissingToken -> {
|
||||||
sendEvent(LoginEvent.ShowErrorDialog(messageRes = R.string.captcha_failed))
|
mutableStateFlow.update {
|
||||||
|
it.copy(
|
||||||
|
errorDialogState = BasicDialogState.Shown(
|
||||||
|
title = R.string.log_in_denied.asText(),
|
||||||
|
message = R.string.captcha_failed.asText(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is CaptchaCallbackTokenResult.Success -> attemptLogin(captchaToken = tokenResult.token)
|
is CaptchaCallbackTokenResult.Success -> attemptLogin(captchaToken = tokenResult.token)
|
||||||
|
@ -87,38 +136,25 @@ class LoginViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun attemptLogin(captchaToken: String?) {
|
private fun attemptLogin(captchaToken: String?) {
|
||||||
viewModelScope.launch {
|
mutableStateFlow.update {
|
||||||
// TODO: show progress here BIT-320
|
it.copy(
|
||||||
sendEvent(
|
loadingDialogState = LoadingDialogState.Shown(
|
||||||
event = LoginEvent.ShowToast(
|
text = R.string.logging_in.asText(),
|
||||||
message = "Loading...",
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
val result = authRepository.login(
|
val result = authRepository.login(
|
||||||
email = mutableStateFlow.value.emailAddress,
|
email = mutableStateFlow.value.emailAddress,
|
||||||
password = mutableStateFlow.value.passwordInput,
|
password = mutableStateFlow.value.passwordInput,
|
||||||
captchaToken = captchaToken,
|
captchaToken = captchaToken,
|
||||||
)
|
)
|
||||||
when (result) {
|
sendAction(
|
||||||
// TODO: show an error here BIT-320
|
LoginAction.Internal.ReceiveLoginResult(
|
||||||
LoginResult.Error -> {
|
loginResult = result,
|
||||||
sendEvent(
|
|
||||||
event = LoginEvent.ShowToast(
|
|
||||||
message = "Error when logging in",
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// No action required on success, root nav will navigate to logged in state
|
|
||||||
LoginResult.Success -> Unit
|
|
||||||
is LoginResult.CaptchaRequired -> {
|
|
||||||
sendEvent(
|
|
||||||
event = LoginEvent.NavigateToCaptcha(
|
|
||||||
intent = result.generateIntentForCaptcha(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleMasterPasswordHintClicked() {
|
private fun handleMasterPasswordHintClicked() {
|
||||||
|
@ -149,6 +185,8 @@ data class LoginState(
|
||||||
val emailAddress: String,
|
val emailAddress: String,
|
||||||
val region: String,
|
val region: String,
|
||||||
val isLoginButtonEnabled: Boolean,
|
val isLoginButtonEnabled: Boolean,
|
||||||
|
val loadingDialogState: LoadingDialogState,
|
||||||
|
val errorDialogState: BasicDialogState,
|
||||||
) : Parcelable
|
) : Parcelable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -165,11 +203,6 @@ sealed class LoginEvent {
|
||||||
*/
|
*/
|
||||||
data class NavigateToCaptcha(val intent: Intent) : LoginEvent()
|
data class NavigateToCaptcha(val intent: Intent) : LoginEvent()
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows an error pop up with a given message
|
|
||||||
*/
|
|
||||||
data class ShowErrorDialog(@StringRes val messageRes: Int) : LoginEvent()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a toast with the given [message].
|
* Shows a toast with the given [message].
|
||||||
*/
|
*/
|
||||||
|
@ -205,6 +238,11 @@ sealed class LoginAction {
|
||||||
*/
|
*/
|
||||||
data object SingleSignOnClick : LoginAction()
|
data object SingleSignOnClick : LoginAction()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates that the error dialog has been dismissed.
|
||||||
|
*/
|
||||||
|
data object ErrorDialogDismiss : LoginAction()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Indicates that the password input has changed.
|
* Indicates that the password input has changed.
|
||||||
*/
|
*/
|
||||||
|
@ -220,5 +258,12 @@ sealed class LoginAction {
|
||||||
data class ReceiveCaptchaToken(
|
data class ReceiveCaptchaToken(
|
||||||
val tokenResult: CaptchaCallbackTokenResult,
|
val tokenResult: CaptchaCallbackTokenResult,
|
||||||
) : Internal()
|
) : Internal()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates a login result has been received.
|
||||||
|
*/
|
||||||
|
data class ReceiveLoginResult(
|
||||||
|
val loginResult: LoginResult,
|
||||||
|
) : Internal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,8 +6,11 @@ import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import com.x8bit.bitwarden.R
|
import com.x8bit.bitwarden.R
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||||
|
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||||
|
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -46,6 +49,21 @@ fun BitwardenBasicDialog(
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun BitwardenBasicDialog_preview() {
|
||||||
|
BitwardenTheme {
|
||||||
|
BitwardenBasicDialog(
|
||||||
|
visibilityState = BasicDialogState.Shown(
|
||||||
|
title = "An error has occurred.".asText(),
|
||||||
|
message = "Username or password is incorrect. Try again.".asText(),
|
||||||
|
),
|
||||||
|
onDismissRequest = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,105 @@
|
||||||
|
package com.x8bit.bitwarden.ui.platform.components
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||||
|
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||||
|
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a Bitwarden-styled loading dialog that shows text and a circular progress indicator.
|
||||||
|
*
|
||||||
|
* @param visibilityState the [LoadingDialogState] used to populate the dialog.
|
||||||
|
* @param onDismissRequest called when the user has requested to dismiss the dialog, whether by
|
||||||
|
* * tapping "OK", tapping outside the dialog, or pressing the back button
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun BitwardenLoadingDialog(
|
||||||
|
visibilityState: LoadingDialogState,
|
||||||
|
) {
|
||||||
|
when (visibilityState) {
|
||||||
|
is LoadingDialogState.Hidden -> Unit
|
||||||
|
is LoadingDialogState.Shown -> {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {},
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(28.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.wrapContentHeight(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = visibilityState.text(),
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
top = 24.dp,
|
||||||
|
bottom = 8.dp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
top = 8.dp,
|
||||||
|
bottom = 24.dp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun BitwardenLoadingDialog_preview() {
|
||||||
|
BitwardenTheme {
|
||||||
|
BitwardenLoadingDialog(
|
||||||
|
visibilityState = LoadingDialogState.Shown(
|
||||||
|
text = "Loading...".asText(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Models display of a [BitwardenLoadingDialog].
|
||||||
|
*/
|
||||||
|
sealed class LoadingDialogState : Parcelable {
|
||||||
|
/**
|
||||||
|
* Hide the dialog.
|
||||||
|
*/
|
||||||
|
@Parcelize
|
||||||
|
data object Hidden : LoadingDialogState()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the dialog with the given values.
|
||||||
|
*/
|
||||||
|
@Parcelize
|
||||||
|
data class Shown(val text: Text) : LoadingDialogState()
|
||||||
|
}
|
|
@ -54,6 +54,17 @@ class IdentityServiceTest : BaseServiceTest() {
|
||||||
assertEquals(Result.success(CAPTCHA_BODY), result)
|
assertEquals(Result.success(CAPTCHA_BODY), result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getToken when response is a 400 with an error body should return Invalid`() = runTest {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(400).setBody(INVALID_LOGIN_JSON))
|
||||||
|
val result = identityService.getToken(
|
||||||
|
email = EMAIL,
|
||||||
|
passwordHash = PASSWORD_HASH,
|
||||||
|
captchaToken = null,
|
||||||
|
)
|
||||||
|
assertEquals(Result.success(INVALID_LOGIN), result)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val EMAIL = "email"
|
private const val EMAIL = "email"
|
||||||
private const val PASSWORD_HASH = "passwordHash"
|
private const val PASSWORD_HASH = "passwordHash"
|
||||||
|
@ -73,3 +84,17 @@ private const val LOGIN_SUCCESS_JSON = """
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
private val LOGIN_SUCCESS = GetTokenResponseJson.Success("123")
|
private val LOGIN_SUCCESS = GetTokenResponseJson.Success("123")
|
||||||
|
|
||||||
|
private const val INVALID_LOGIN_JSON = """
|
||||||
|
{
|
||||||
|
"ErrorModel": {
|
||||||
|
"Message": "123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
private val INVALID_LOGIN = GetTokenResponseJson.Invalid(
|
||||||
|
errorModel = GetTokenResponseJson.Invalid.ErrorModel(
|
||||||
|
errorMessage = "123",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
|
@ -50,18 +50,18 @@ class AuthRepositoryTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `login when pre login fails should return Error`() = runTest {
|
fun `login when pre login fails should return Error with no message`() = runTest {
|
||||||
coEvery {
|
coEvery {
|
||||||
accountsService.preLogin(email = EMAIL)
|
accountsService.preLogin(email = EMAIL)
|
||||||
} returns (Result.failure(RuntimeException()))
|
} returns (Result.failure(RuntimeException()))
|
||||||
val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
|
val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
|
||||||
assertEquals(LoginResult.Error, result)
|
assertEquals(LoginResult.Error(errorMessage = null), result)
|
||||||
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
|
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
|
||||||
coVerify { accountsService.preLogin(email = EMAIL) }
|
coVerify { accountsService.preLogin(email = EMAIL) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `login get token fails should return Error`() = runTest {
|
fun `login get token fails should return Error with no message`() = runTest {
|
||||||
coEvery {
|
coEvery {
|
||||||
accountsService.preLogin(email = EMAIL)
|
accountsService.preLogin(email = EMAIL)
|
||||||
} returns Result.success(PRE_LOGIN_SUCCESS)
|
} returns Result.success(PRE_LOGIN_SUCCESS)
|
||||||
|
@ -74,7 +74,41 @@ class AuthRepositoryTest {
|
||||||
}
|
}
|
||||||
.returns(Result.failure(RuntimeException()))
|
.returns(Result.failure(RuntimeException()))
|
||||||
val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
|
val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
|
||||||
assertEquals(LoginResult.Error, result)
|
assertEquals(LoginResult.Error(errorMessage = null), result)
|
||||||
|
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
|
||||||
|
coVerify { accountsService.preLogin(email = EMAIL) }
|
||||||
|
coVerify {
|
||||||
|
identityService.getToken(
|
||||||
|
email = EMAIL,
|
||||||
|
passwordHash = PASSWORD_HASH,
|
||||||
|
captchaToken = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `login get token returns Invalid should return Error with correct message`() = runTest {
|
||||||
|
coEvery {
|
||||||
|
accountsService.preLogin(email = EMAIL)
|
||||||
|
} returns Result.success(PRE_LOGIN_SUCCESS)
|
||||||
|
coEvery {
|
||||||
|
identityService.getToken(
|
||||||
|
email = EMAIL,
|
||||||
|
passwordHash = PASSWORD_HASH,
|
||||||
|
captchaToken = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.returns(
|
||||||
|
Result.success(
|
||||||
|
GetTokenResponseJson.Invalid(
|
||||||
|
errorModel = GetTokenResponseJson.Invalid.ErrorModel(
|
||||||
|
errorMessage = "mock_error_message",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
|
||||||
|
assertEquals(LoginResult.Error(errorMessage = "mock_error_message"), result)
|
||||||
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
|
assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value)
|
||||||
coVerify { accountsService.preLogin(email = EMAIL) }
|
coVerify { accountsService.preLogin(email = EMAIL) }
|
||||||
coVerify {
|
coVerify {
|
||||||
|
|
|
@ -14,6 +14,8 @@ import androidx.compose.ui.test.performScrollTo
|
||||||
import androidx.compose.ui.test.performTextInput
|
import androidx.compose.ui.test.performTextInput
|
||||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
|
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
|
||||||
|
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
|
||||||
|
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
|
@ -35,6 +37,8 @@ class LoginScreenTest : BaseComposeTest() {
|
||||||
isLoginButtonEnabled = false,
|
isLoginButtonEnabled = false,
|
||||||
passwordInput = "",
|
passwordInput = "",
|
||||||
region = "",
|
region = "",
|
||||||
|
loadingDialogState = LoadingDialogState.Hidden,
|
||||||
|
errorDialogState = BasicDialogState.Hidden,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -60,6 +64,8 @@ class LoginScreenTest : BaseComposeTest() {
|
||||||
isLoginButtonEnabled = false,
|
isLoginButtonEnabled = false,
|
||||||
passwordInput = "",
|
passwordInput = "",
|
||||||
region = "",
|
region = "",
|
||||||
|
loadingDialogState = LoadingDialogState.Hidden,
|
||||||
|
errorDialogState = BasicDialogState.Hidden,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -85,6 +91,8 @@ class LoginScreenTest : BaseComposeTest() {
|
||||||
isLoginButtonEnabled = false,
|
isLoginButtonEnabled = false,
|
||||||
passwordInput = "",
|
passwordInput = "",
|
||||||
region = "",
|
region = "",
|
||||||
|
loadingDialogState = LoadingDialogState.Hidden,
|
||||||
|
errorDialogState = BasicDialogState.Hidden,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -110,6 +118,8 @@ class LoginScreenTest : BaseComposeTest() {
|
||||||
isLoginButtonEnabled = false,
|
isLoginButtonEnabled = false,
|
||||||
passwordInput = "",
|
passwordInput = "",
|
||||||
region = "",
|
region = "",
|
||||||
|
loadingDialogState = LoadingDialogState.Hidden,
|
||||||
|
errorDialogState = BasicDialogState.Hidden,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -147,6 +157,8 @@ class LoginScreenTest : BaseComposeTest() {
|
||||||
isLoginButtonEnabled = false,
|
isLoginButtonEnabled = false,
|
||||||
passwordInput = "",
|
passwordInput = "",
|
||||||
region = "",
|
region = "",
|
||||||
|
loadingDialogState = LoadingDialogState.Hidden,
|
||||||
|
errorDialogState = BasicDialogState.Hidden,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -173,6 +185,8 @@ class LoginScreenTest : BaseComposeTest() {
|
||||||
isLoginButtonEnabled = false,
|
isLoginButtonEnabled = false,
|
||||||
passwordInput = "",
|
passwordInput = "",
|
||||||
region = "",
|
region = "",
|
||||||
|
loadingDialogState = LoadingDialogState.Hidden,
|
||||||
|
errorDialogState = BasicDialogState.Hidden,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -199,6 +213,8 @@ class LoginScreenTest : BaseComposeTest() {
|
||||||
isLoginButtonEnabled = false,
|
isLoginButtonEnabled = false,
|
||||||
passwordInput = "",
|
passwordInput = "",
|
||||||
region = "",
|
region = "",
|
||||||
|
loadingDialogState = LoadingDialogState.Hidden,
|
||||||
|
errorDialogState = BasicDialogState.Hidden,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,11 +3,15 @@ package com.x8bit.bitwarden.ui.auth.feature.login
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
|
import com.x8bit.bitwarden.R
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.network.util.CaptchaCallbackTokenResult
|
import com.x8bit.bitwarden.data.auth.datasource.network.util.CaptchaCallbackTokenResult
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.network.util.generateIntentForCaptcha
|
import com.x8bit.bitwarden.data.auth.datasource.network.util.generateIntentForCaptcha
|
||||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||||
|
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||||
|
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
|
||||||
|
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
|
@ -92,8 +96,7 @@ class LoginViewModelTest : BaseViewModelTest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `LoginButtonClick login returns error should emit error ShowToast`() = runTest {
|
fun `LoginButtonClick login returns error should update errorDialogState`() = runTest {
|
||||||
// TODO: handle and display errors (BIT-320)
|
|
||||||
val authRepository = mockk<AuthRepository> {
|
val authRepository = mockk<AuthRepository> {
|
||||||
coEvery {
|
coEvery {
|
||||||
login(
|
login(
|
||||||
|
@ -101,19 +104,32 @@ class LoginViewModelTest : BaseViewModelTest() {
|
||||||
password = "",
|
password = "",
|
||||||
captchaToken = null,
|
captchaToken = null,
|
||||||
)
|
)
|
||||||
} returns LoginResult.Error
|
} returns LoginResult.Error(errorMessage = "mock_error")
|
||||||
every { captchaTokenResultFlow } returns flowOf()
|
every { captchaTokenResultFlow } returns flowOf()
|
||||||
}
|
}
|
||||||
val viewModel = LoginViewModel(
|
val viewModel = LoginViewModel(
|
||||||
authRepository = authRepository,
|
authRepository = authRepository,
|
||||||
savedStateHandle = savedStateHandle,
|
savedStateHandle = savedStateHandle,
|
||||||
)
|
)
|
||||||
viewModel.eventFlow.test {
|
viewModel.stateFlow.test {
|
||||||
viewModel.actionChannel.trySend(LoginAction.LoginButtonClick)
|
assertEquals(DEFAULT_STATE, awaitItem())
|
||||||
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
|
viewModel.trySendAction(LoginAction.LoginButtonClick)
|
||||||
assertEquals(LoginEvent.ShowToast("Loading..."), awaitItem())
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
LoginEvent.ShowToast("Error when logging in"),
|
DEFAULT_STATE.copy(
|
||||||
|
loadingDialogState = LoadingDialogState.Shown(
|
||||||
|
text = R.string.logging_in.asText(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
awaitItem(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DEFAULT_STATE.copy(
|
||||||
|
errorDialogState = BasicDialogState.Shown(
|
||||||
|
title = R.string.an_error_has_occurred.asText(),
|
||||||
|
message = "mock_error".asText(),
|
||||||
|
),
|
||||||
|
loadingDialogState = LoadingDialogState.Hidden,
|
||||||
|
),
|
||||||
awaitItem(),
|
awaitItem(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -123,19 +139,32 @@ class LoginViewModelTest : BaseViewModelTest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `LoginButtonClick login returns success should emit loading ShowToast`() = runTest {
|
fun `LoginButtonClick login returns success should update loadingDialogState`() = runTest {
|
||||||
val authRepository = mockk<AuthRepository> {
|
val authRepository = mockk<AuthRepository> {
|
||||||
coEvery { login("test@gmail.com", "", captchaToken = null) } returns LoginResult.Success
|
coEvery {
|
||||||
|
login("test@gmail.com", "", captchaToken = null)
|
||||||
|
} returns LoginResult.Success
|
||||||
every { captchaTokenResultFlow } returns flowOf()
|
every { captchaTokenResultFlow } returns flowOf()
|
||||||
}
|
}
|
||||||
val viewModel = LoginViewModel(
|
val viewModel = LoginViewModel(
|
||||||
authRepository = authRepository,
|
authRepository = authRepository,
|
||||||
savedStateHandle = savedStateHandle,
|
savedStateHandle = savedStateHandle,
|
||||||
)
|
)
|
||||||
viewModel.eventFlow.test {
|
viewModel.stateFlow.test {
|
||||||
viewModel.actionChannel.trySend(LoginAction.LoginButtonClick)
|
assertEquals(DEFAULT_STATE, awaitItem())
|
||||||
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
|
viewModel.trySendAction(LoginAction.LoginButtonClick)
|
||||||
assertEquals(LoginEvent.ShowToast("Loading..."), awaitItem())
|
assertEquals(
|
||||||
|
DEFAULT_STATE.copy(
|
||||||
|
loadingDialogState = LoadingDialogState.Shown(
|
||||||
|
text = R.string.logging_in.asText(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
awaitItem(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DEFAULT_STATE.copy(loadingDialogState = LoadingDialogState.Hidden),
|
||||||
|
awaitItem(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
coVerify {
|
coVerify {
|
||||||
authRepository.login(email = "test@gmail.com", password = "", captchaToken = null)
|
authRepository.login(email = "test@gmail.com", password = "", captchaToken = null)
|
||||||
|
@ -163,7 +192,6 @@ class LoginViewModelTest : BaseViewModelTest() {
|
||||||
viewModel.eventFlow.test {
|
viewModel.eventFlow.test {
|
||||||
viewModel.actionChannel.trySend(LoginAction.LoginButtonClick)
|
viewModel.actionChannel.trySend(LoginAction.LoginButtonClick)
|
||||||
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
|
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
|
||||||
assertEquals(LoginEvent.ShowToast("Loading..."), awaitItem())
|
|
||||||
assertEquals(LoginEvent.NavigateToCaptcha(intent = mockkIntent), awaitItem())
|
assertEquals(LoginEvent.NavigateToCaptcha(intent = mockkIntent), awaitItem())
|
||||||
}
|
}
|
||||||
coVerify {
|
coVerify {
|
||||||
|
@ -271,6 +299,8 @@ class LoginViewModelTest : BaseViewModelTest() {
|
||||||
passwordInput = "",
|
passwordInput = "",
|
||||||
isLoginButtonEnabled = true,
|
isLoginButtonEnabled = true,
|
||||||
region = "",
|
region = "",
|
||||||
|
loadingDialogState = LoadingDialogState.Hidden,
|
||||||
|
errorDialogState = BasicDialogState.Hidden,
|
||||||
)
|
)
|
||||||
|
|
||||||
private const val LOGIN_RESULT_PATH =
|
private const val LOGIN_RESULT_PATH =
|
||||||
|
|
Loading…
Reference in a new issue