mirror of
https://github.com/bitwarden/android.git
synced 2024-12-18 07:11:51 +03:00
BIT-133 Implement happy path login (#52)
This commit is contained in:
parent
36942ab296
commit
14d01877fe
16 changed files with 432 additions and 33 deletions
|
@ -0,0 +1,18 @@
|
||||||
|
package com.x8bit.bitwarden.data.auth.datasource.network.api
|
||||||
|
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJson
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.POST
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines calls under the /accounts API.
|
||||||
|
*/
|
||||||
|
interface AccountsApi {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make pre login request to get KDF params.
|
||||||
|
*/
|
||||||
|
@POST("/accounts/prelogin")
|
||||||
|
suspend fun preLogin(@Body body: PreLoginRequestJson): Result<PreLoginResponseJson>
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
package com.x8bit.bitwarden.data.auth.datasource.network.api
|
||||||
|
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
|
||||||
|
import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlEncode
|
||||||
|
import retrofit2.http.Field
|
||||||
|
import retrofit2.http.FormUrlEncoded
|
||||||
|
import retrofit2.http.Header
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Url
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines calls under the /identity API.
|
||||||
|
*/
|
||||||
|
interface IdentityApi {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make request to get an access token.
|
||||||
|
*/
|
||||||
|
@POST
|
||||||
|
@Suppress("LongParameterList")
|
||||||
|
@FormUrlEncoded
|
||||||
|
suspend fun getToken(
|
||||||
|
// TODO: use correct base URL here BIT-328
|
||||||
|
@Url url: String = "https://vault.bitwarden.com/identity/connect/token",
|
||||||
|
@Field(value = "scope", encoded = true) scope: String = "api+offline_access",
|
||||||
|
@Field(value = "client_id") clientId: String = "mobile",
|
||||||
|
@Field(value = "username") email: String,
|
||||||
|
@Header(value = "auth-email") authEmail: String = email.base64UrlEncode(),
|
||||||
|
@Field(value = "password") passwordHash: String,
|
||||||
|
// TODO: use correct device identifier here BIT-325
|
||||||
|
@Field(value = "deviceIdentifier") deviceIdentifier: String = UUID.randomUUID().toString(),
|
||||||
|
// TODO: use correct values for deviceName and deviceType BIT-326
|
||||||
|
@Field(value = "deviceName") deviceName: String = "Pixel 6",
|
||||||
|
@Field(value = "deviceType") deviceType: String = "1",
|
||||||
|
@Field(value = "grant_type") grantType: String = "password",
|
||||||
|
): Result<GetTokenResponseJson>
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
package com.x8bit.bitwarden.data.auth.datasource.network.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Models high level auth state for the application.
|
||||||
|
*/
|
||||||
|
sealed class AuthState {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth state is unknown.
|
||||||
|
*/
|
||||||
|
data object Uninitialized : AuthState()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User is unauthenticated. Said another way, the app has no access token.
|
||||||
|
*/
|
||||||
|
data object Unauthenticated : AuthState()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User is authenticated with the given access token.
|
||||||
|
*/
|
||||||
|
data class Authenticated(val accessToken: String) : AuthState()
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
package com.x8bit.bitwarden.data.auth.datasource.network.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Models json response of the get token request.
|
||||||
|
*
|
||||||
|
* @param accessToken the access token.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class GetTokenResponseJson(
|
||||||
|
@SerialName("access_token")
|
||||||
|
val accessToken: String,
|
||||||
|
)
|
|
@ -0,0 +1,19 @@
|
||||||
|
package com.x8bit.bitwarden.data.auth.datasource.network.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Models result of logging in.
|
||||||
|
*
|
||||||
|
* TODO: Add more detail to these cases to expose server error messages (BIT-320)
|
||||||
|
*/
|
||||||
|
sealed class LoginResult {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login succeeded.
|
||||||
|
*/
|
||||||
|
data object Success : LoginResult()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* There was an error logging in.
|
||||||
|
*/
|
||||||
|
data object Error : LoginResult()
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
package com.x8bit.bitwarden.data.auth.datasource.network.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body for pre login.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PreLoginRequestJson(
|
||||||
|
@SerialName("email")
|
||||||
|
val email: String,
|
||||||
|
)
|
|
@ -0,0 +1,20 @@
|
||||||
|
package com.x8bit.bitwarden.data.auth.datasource.network.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response body for pre login.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PreLoginResponseJson(
|
||||||
|
// TODO parse this property as an enum (BIT-329)
|
||||||
|
@SerialName("kdf")
|
||||||
|
val kdf: Int,
|
||||||
|
@SerialName("kdfIterations")
|
||||||
|
val kdfIterations: UInt,
|
||||||
|
@SerialName("kdfMemory")
|
||||||
|
val kdfMemory: Int? = null,
|
||||||
|
@SerialName("kdfParallelism")
|
||||||
|
val kdfParallelism: Int? = null,
|
||||||
|
)
|
|
@ -0,0 +1,24 @@
|
||||||
|
package com.x8bit.bitwarden.data.auth.repository
|
||||||
|
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides an API for observing an modifying authentication state.
|
||||||
|
*/
|
||||||
|
interface AuthRepository {
|
||||||
|
/**
|
||||||
|
* Models the current auth state.
|
||||||
|
*/
|
||||||
|
val authStateFlow: StateFlow<AuthState>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to login with the given email and password. Updated access token will be reflected
|
||||||
|
* in [authStateFlow].
|
||||||
|
*/
|
||||||
|
suspend fun login(
|
||||||
|
email: String,
|
||||||
|
password: String,
|
||||||
|
): LoginResult
|
||||||
|
}
|
|
@ -0,0 +1,62 @@
|
||||||
|
package com.x8bit.bitwarden.data.auth.repository
|
||||||
|
|
||||||
|
import com.bitwarden.core.Kdf
|
||||||
|
import com.bitwarden.sdk.Client
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.network.api.AccountsApi
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.network.api.IdentityApi
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJson
|
||||||
|
import com.x8bit.bitwarden.data.platform.util.flatMap
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default implementation of [AuthRepository].
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AuthRepositoryImpl @Inject constructor(
|
||||||
|
private val accountsApi: AccountsApi,
|
||||||
|
private val identityApi: IdentityApi,
|
||||||
|
private val bitwardenSdkClient: Client,
|
||||||
|
) : AuthRepository {
|
||||||
|
|
||||||
|
private val mutableAccessTokenFlow = MutableStateFlow<AuthState>(AuthState.Unauthenticated)
|
||||||
|
override val authStateFlow: StateFlow<AuthState> = mutableAccessTokenFlow.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to login with the given email.
|
||||||
|
*/
|
||||||
|
override suspend fun login(
|
||||||
|
email: String,
|
||||||
|
password: String,
|
||||||
|
): LoginResult = accountsApi
|
||||||
|
.preLogin(PreLoginRequestJson(email))
|
||||||
|
.flatMap {
|
||||||
|
// TODO: Use KDF enum from pre login correctly (BIT-329)
|
||||||
|
val passwordHash = bitwardenSdkClient
|
||||||
|
.auth()
|
||||||
|
.hashPassword(
|
||||||
|
email = email,
|
||||||
|
password = password,
|
||||||
|
kdfParams = Kdf.Pbkdf2(it.kdfIterations),
|
||||||
|
)
|
||||||
|
identityApi.getToken(
|
||||||
|
email = email,
|
||||||
|
passwordHash = passwordHash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.fold(
|
||||||
|
onFailure = {
|
||||||
|
// TODO: Add more detail to these cases to expose server error messages (BIT-320)
|
||||||
|
LoginResult.Error
|
||||||
|
},
|
||||||
|
onSuccess = {
|
||||||
|
mutableAccessTokenFlow.value = AuthState.Authenticated(it.accessToken)
|
||||||
|
LoginResult.Success
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
package com.x8bit.bitwarden.data.auth.repository.di
|
||||||
|
|
||||||
|
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||||
|
import com.x8bit.bitwarden.data.auth.repository.AuthRepositoryImpl
|
||||||
|
import dagger.Binds
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides repositories in the auth package.
|
||||||
|
*/
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
abstract class RepositoryModule {
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
abstract fun bindsAuthRepository(authRepositoryImpl: AuthRepositoryImpl): AuthRepository
|
||||||
|
}
|
|
@ -1,6 +1,8 @@
|
||||||
package com.x8bit.bitwarden.data.platform.datasource.network.di
|
package com.x8bit.bitwarden.data.platform.datasource.network.di
|
||||||
|
|
||||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.network.api.AccountsApi
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.network.api.IdentityApi
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.network.api.ConfigApi
|
import com.x8bit.bitwarden.data.platform.datasource.network.api.ConfigApi
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.network.core.ResultCallAdapterFactory
|
import com.x8bit.bitwarden.data.platform.datasource.network.core.ResultCallAdapterFactory
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
|
@ -12,6 +14,7 @@ import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.create
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -24,9 +27,15 @@ object NetworkModule {
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideConfigApiService(retrofit: Retrofit): ConfigApi {
|
fun providesAccountsApiService(retrofit: Retrofit): AccountsApi = retrofit.create()
|
||||||
return retrofit.create(ConfigApi::class.java)
|
|
||||||
}
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun providesConfigApiService(retrofit: Retrofit): ConfigApi = retrofit.create()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun providesIdentityApiService(retrofit: Retrofit): IdentityApi = retrofit.create()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
|
@ -42,14 +51,27 @@ object NetworkModule {
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
|
fun provideRetrofit(
|
||||||
|
okHttpClient: OkHttpClient,
|
||||||
|
json: Json,
|
||||||
|
): Retrofit {
|
||||||
val contentType = "application/json".toMediaType()
|
val contentType = "application/json".toMediaType()
|
||||||
|
|
||||||
return Retrofit.Builder()
|
return Retrofit.Builder()
|
||||||
.baseUrl("https://api.bitwarden.com")
|
.baseUrl("https://api.bitwarden.com")
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
.addConverterFactory(Json.asConverterFactory(contentType))
|
.addConverterFactory(json.asConverterFactory(contentType))
|
||||||
.addCallAdapterFactory(ResultCallAdapterFactory())
|
.addCallAdapterFactory(ResultCallAdapterFactory())
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun providesJson(): Json = Json {
|
||||||
|
|
||||||
|
// If there are keys returned by the server not modeled by a serializable class,
|
||||||
|
// ignore them.
|
||||||
|
// This makes additive server changes non-breaking.
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
package com.x8bit.bitwarden.data.platform.datasource.network.util
|
||||||
|
|
||||||
|
import java.util.Base64
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base 64 encode the string as well as make special modifications required by the backend:
|
||||||
|
*
|
||||||
|
* - replace all "+" with "-"
|
||||||
|
* - replace all "/" with "_"
|
||||||
|
* - replace all "=" with ""
|
||||||
|
*/
|
||||||
|
fun String.base64UrlEncode(): String =
|
||||||
|
Base64.getEncoder()
|
||||||
|
.encodeToString(toByteArray())
|
||||||
|
.replace("+", "-")
|
||||||
|
.replace("/", "_")
|
||||||
|
.replace("=", "")
|
|
@ -3,11 +3,14 @@ package com.x8bit.bitwarden.ui.auth.feature.login
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
|
||||||
|
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 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
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ -18,12 +21,13 @@ private const val KEY_STATE = "state"
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class LoginViewModel @Inject constructor(
|
class LoginViewModel @Inject constructor(
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : BaseViewModel<LoginState, LoginEvent, LoginAction>(
|
) : BaseViewModel<LoginState, LoginEvent, LoginAction>(
|
||||||
initialState = savedStateHandle[KEY_STATE]
|
initialState = savedStateHandle[KEY_STATE]
|
||||||
?: LoginState(
|
?: LoginState(
|
||||||
emailAddress = LoginArgs(savedStateHandle).emailAddress,
|
emailAddress = LoginArgs(savedStateHandle).emailAddress,
|
||||||
isLoginButtonEnabled = false,
|
isLoginButtonEnabled = true,
|
||||||
passwordInput = "",
|
passwordInput = "",
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
|
@ -45,7 +49,19 @@ class LoginViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleLoginButtonClicked() {
|
private fun handleLoginButtonClicked() {
|
||||||
// TODO BIT-133 make login request and allow user to login
|
viewModelScope.launch {
|
||||||
|
// TODO: show progress here BIT-320
|
||||||
|
val result = authRepository.login(
|
||||||
|
email = mutableStateFlow.value.emailAddress,
|
||||||
|
password = mutableStateFlow.value.passwordInput,
|
||||||
|
)
|
||||||
|
when (result) {
|
||||||
|
// TODO: show an error here BIT-320
|
||||||
|
LoginResult.Error -> Unit
|
||||||
|
// No action required on success, root nav will navigate to logged in state
|
||||||
|
LoginResult.Success -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleNotYouButtonClicked() {
|
private fun handleNotYouButtonClicked() {
|
||||||
|
|
|
@ -3,13 +3,13 @@ package com.x8bit.bitwarden.ui.platform.feature.rootnav
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
|
||||||
|
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 dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ -20,8 +20,9 @@ private const val KEY_NAV_DESTINATION = "nav_state"
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class RootNavViewModel @Inject constructor(
|
class RootNavViewModel @Inject constructor(
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
private val savedStateHandle: SavedStateHandle,
|
private val savedStateHandle: SavedStateHandle,
|
||||||
) : BaseViewModel<RootNavState, Unit, Unit>(
|
) : BaseViewModel<RootNavState, Unit, RootNavAction>(
|
||||||
initialState = RootNavState.Splash,
|
initialState = RootNavState.Splash,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
@ -39,14 +40,25 @@ class RootNavViewModel @Inject constructor(
|
||||||
stateFlow
|
stateFlow
|
||||||
.onEach { savedRootNavState = it }
|
.onEach { savedRootNavState = it }
|
||||||
.launchIn(viewModelScope)
|
.launchIn(viewModelScope)
|
||||||
viewModelScope.launch {
|
authRepository
|
||||||
@Suppress("MagicNumber")
|
.authStateFlow
|
||||||
delay(1000)
|
.onEach { trySendAction(RootNavAction.AuthStateUpdated(it)) }
|
||||||
mutableStateFlow.update { RootNavState.Auth }
|
.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleAction(action: RootNavAction) {
|
||||||
|
when (action) {
|
||||||
|
is RootNavAction.AuthStateUpdated -> handleAuthStateUpdated(action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleAction(action: Unit) = Unit
|
private fun handleAuthStateUpdated(action: RootNavAction.AuthStateUpdated) {
|
||||||
|
when (action.newState) {
|
||||||
|
is AuthState.Authenticated -> mutableStateFlow.update { RootNavState.VaultUnlocked }
|
||||||
|
is AuthState.Unauthenticated -> mutableStateFlow.update { RootNavState.Auth }
|
||||||
|
is AuthState.Uninitialized -> mutableStateFlow.update { RootNavState.Splash }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -71,3 +83,14 @@ sealed class RootNavState : Parcelable {
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data object VaultUnlocked : RootNavState()
|
data object VaultUnlocked : RootNavState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Models root level navigation actions.
|
||||||
|
*/
|
||||||
|
sealed class RootNavAction {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth state in the repository layer changed.
|
||||||
|
*/
|
||||||
|
data class AuthStateUpdated(val newState: AuthState) : RootNavAction()
|
||||||
|
}
|
||||||
|
|
|
@ -2,7 +2,12 @@ package com.x8bit.bitwarden.ui.auth.feature.login
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
|
||||||
|
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 io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
@ -15,7 +20,10 @@ class LoginViewModelTest : BaseViewModelTest() {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `initial state should be correct`() = runTest {
|
fun `initial state should be correct`() = runTest {
|
||||||
val viewModel = LoginViewModel(savedStateHandle)
|
val viewModel = LoginViewModel(
|
||||||
|
authRepository = mockk(),
|
||||||
|
savedStateHandle = savedStateHandle,
|
||||||
|
)
|
||||||
viewModel.stateFlow.test {
|
viewModel.stateFlow.test {
|
||||||
assertEquals(DEFAULT_STATE, awaitItem())
|
assertEquals(DEFAULT_STATE, awaitItem())
|
||||||
}
|
}
|
||||||
|
@ -33,26 +41,56 @@ class LoginViewModelTest : BaseViewModelTest() {
|
||||||
"state" to expectedState,
|
"state" to expectedState,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val viewModel = LoginViewModel(handle)
|
val viewModel = LoginViewModel(
|
||||||
|
authRepository = mockk(),
|
||||||
|
savedStateHandle = handle,
|
||||||
|
)
|
||||||
viewModel.stateFlow.test {
|
viewModel.stateFlow.test {
|
||||||
assertEquals(expectedState, awaitItem())
|
assertEquals(expectedState, awaitItem())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `LoginButtonClick should do nothing`() = runTest {
|
fun `LoginButtonClick login returns error should do nothing`() = runTest {
|
||||||
|
// TODO: handle and display errors (BIT-320)
|
||||||
|
val authRepository = mockk<AuthRepository> {
|
||||||
|
coEvery { login(email = "test@gmail.com", password = "") } returns LoginResult.Error
|
||||||
|
}
|
||||||
val viewModel = LoginViewModel(
|
val viewModel = LoginViewModel(
|
||||||
|
authRepository = authRepository,
|
||||||
savedStateHandle = savedStateHandle,
|
savedStateHandle = savedStateHandle,
|
||||||
)
|
)
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
coVerify {
|
||||||
|
authRepository.login(email = "test@gmail.com", password = "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `LoginButtonClick login returns success should do nothing`() = runTest {
|
||||||
|
val authRepository = mockk<AuthRepository> {
|
||||||
|
coEvery { login("test@gmail.com", "") } returns LoginResult.Success
|
||||||
|
}
|
||||||
|
val viewModel = LoginViewModel(
|
||||||
|
authRepository = authRepository,
|
||||||
|
savedStateHandle = savedStateHandle,
|
||||||
|
)
|
||||||
|
viewModel.eventFlow.test {
|
||||||
|
viewModel.actionChannel.trySend(LoginAction.LoginButtonClick)
|
||||||
|
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
|
||||||
|
}
|
||||||
|
coVerify {
|
||||||
|
authRepository.login(email = "test@gmail.com", password = "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `SingleSignOnClick should do nothing`() = runTest {
|
fun `SingleSignOnClick should do nothing`() = runTest {
|
||||||
val viewModel = LoginViewModel(
|
val viewModel = LoginViewModel(
|
||||||
|
authRepository = mockk(),
|
||||||
savedStateHandle = savedStateHandle,
|
savedStateHandle = savedStateHandle,
|
||||||
)
|
)
|
||||||
viewModel.eventFlow.test {
|
viewModel.eventFlow.test {
|
||||||
|
@ -64,6 +102,7 @@ class LoginViewModelTest : BaseViewModelTest() {
|
||||||
@Test
|
@Test
|
||||||
fun `NotYouButtonClick should emit NavigateToLanding`() = runTest {
|
fun `NotYouButtonClick should emit NavigateToLanding`() = runTest {
|
||||||
val viewModel = LoginViewModel(
|
val viewModel = LoginViewModel(
|
||||||
|
authRepository = mockk(),
|
||||||
savedStateHandle = savedStateHandle,
|
savedStateHandle = savedStateHandle,
|
||||||
)
|
)
|
||||||
viewModel.eventFlow.test {
|
viewModel.eventFlow.test {
|
||||||
|
@ -79,6 +118,7 @@ class LoginViewModelTest : BaseViewModelTest() {
|
||||||
fun `PasswordInputChanged should update password input`() = runTest {
|
fun `PasswordInputChanged should update password input`() = runTest {
|
||||||
val input = "input"
|
val input = "input"
|
||||||
val viewModel = LoginViewModel(
|
val viewModel = LoginViewModel(
|
||||||
|
authRepository = mockk(),
|
||||||
savedStateHandle = savedStateHandle,
|
savedStateHandle = savedStateHandle,
|
||||||
)
|
)
|
||||||
viewModel.eventFlow.test {
|
viewModel.eventFlow.test {
|
||||||
|
@ -94,7 +134,7 @@ class LoginViewModelTest : BaseViewModelTest() {
|
||||||
private val DEFAULT_STATE = LoginState(
|
private val DEFAULT_STATE = LoginState(
|
||||||
emailAddress = "test@gmail.com",
|
emailAddress = "test@gmail.com",
|
||||||
passwordInput = "",
|
passwordInput = "",
|
||||||
isLoginButtonEnabled = false,
|
isLoginButtonEnabled = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,33 +1,64 @@
|
||||||
package com.x8bit.bitwarden.ui.platform.feature.rootnav
|
package com.x8bit.bitwarden.ui.platform.feature.rootnav
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import app.cash.turbine.test
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState.Authenticated
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState.Unauthenticated
|
||||||
|
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 io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
class RootNavViewModelTest : BaseViewModelTest() {
|
class RootNavViewModelTest : BaseViewModelTest() {
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `initial state should be splash`() {
|
|
||||||
val viewModel = RootNavViewModel(SavedStateHandle())
|
|
||||||
assertEquals(viewModel.stateFlow.value, RootNavState.Splash)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `initial state should be the state in savedStateHandle`() {
|
fun `initial state should be the state in savedStateHandle`() {
|
||||||
|
val authRepository = mockk<AuthRepository> {
|
||||||
|
every { this@mockk.authStateFlow } returns MutableStateFlow(mockk<Authenticated>())
|
||||||
|
}
|
||||||
val handle = SavedStateHandle(mapOf(("nav_state" to RootNavState.VaultUnlocked)))
|
val handle = SavedStateHandle(mapOf(("nav_state" to RootNavState.VaultUnlocked)))
|
||||||
val viewModel = RootNavViewModel(handle)
|
val viewModel = RootNavViewModel(
|
||||||
assertEquals(viewModel.stateFlow.value, RootNavState.VaultUnlocked)
|
authRepository = authRepository,
|
||||||
|
savedStateHandle = handle,
|
||||||
|
)
|
||||||
|
assertEquals(RootNavState.VaultUnlocked, viewModel.stateFlow.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `state should move from splash to auth`() = runTest {
|
fun `when auth state is Uninitialized nav state should be Splash`() {
|
||||||
val viewModel = RootNavViewModel(SavedStateHandle())
|
val viewModel = RootNavViewModel(
|
||||||
viewModel.stateFlow.test {
|
authRepository = mockk {
|
||||||
assertEquals(awaitItem(), RootNavState.Splash)
|
every { this@mockk.authStateFlow } returns MutableStateFlow(AuthState.Uninitialized)
|
||||||
assertEquals(awaitItem(), RootNavState.Auth)
|
},
|
||||||
|
savedStateHandle = SavedStateHandle(),
|
||||||
|
)
|
||||||
|
assertEquals(RootNavState.Splash, viewModel.stateFlow.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when auth state is Authenticated nav state should be VaultUnlocked`() {
|
||||||
|
val authRepository = mockk<AuthRepository> {
|
||||||
|
every { this@mockk.authStateFlow } returns MutableStateFlow(mockk<Authenticated>())
|
||||||
}
|
}
|
||||||
|
val viewModel = RootNavViewModel(
|
||||||
|
authRepository = authRepository,
|
||||||
|
savedStateHandle = SavedStateHandle(),
|
||||||
|
)
|
||||||
|
assertEquals(RootNavState.VaultUnlocked, viewModel.stateFlow.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when auth state is Unauthenticated nav state should be Auth`() = runTest {
|
||||||
|
val viewModel = RootNavViewModel(
|
||||||
|
authRepository = mockk {
|
||||||
|
every { this@mockk.authStateFlow } returns MutableStateFlow(Unauthenticated)
|
||||||
|
},
|
||||||
|
savedStateHandle = SavedStateHandle(),
|
||||||
|
)
|
||||||
|
assertEquals(RootNavState.Auth, viewModel.stateFlow.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue