diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt index 740b128fd4..cb36e91629 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt @@ -5,13 +5,16 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository -import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult +import com.x8bit.bitwarden.data.auth.repository.model.CreateAuthRequestResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.asText import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import javax.inject.Inject @@ -32,8 +35,10 @@ class LoginWithDeviceViewModel @Inject constructor( dialogState = null, ), ) { + private var authJob: Job = Job().apply { complete() } + init { - sendNewAuthRequest() + sendNewAuthRequest(isResend = false) } override fun handleAction(action: LoginWithDeviceAction) { @@ -58,22 +63,36 @@ class LoginWithDeviceViewModel @Inject constructor( } private fun handleResendNotificationClicked() { - sendNewAuthRequest() + sendNewAuthRequest(isResend = true) } private fun handleViewAllLogInOptionsClicked() { sendEvent(LoginWithDeviceEvent.NavigateBack) } + @Suppress("LongMethod") private fun handleNewAuthRequestResultReceived( action: LoginWithDeviceAction.Internal.NewAuthRequestResultReceive, ) { - when (action.result) { - is AuthRequestResult.Success -> { + when (val result = action.result) { + is CreateAuthRequestResult.Success -> { mutableStateFlow.update { it.copy( viewState = LoginWithDeviceState.ViewState.Content( - fingerprintPhrase = action.result.authRequest.fingerprint, + fingerprintPhrase = "", + isResendNotificationLoading = false, + ), + dialogState = null, + ) + } + // TODO: Unlock the vault (BIT-813) + } + + is CreateAuthRequestResult.Update -> { + mutableStateFlow.update { + it.copy( + viewState = LoginWithDeviceState.ViewState.Content( + fingerprintPhrase = result.authRequest.fingerprint, isResendNotificationLoading = false, ), dialogState = null, @@ -81,7 +100,7 @@ class LoginWithDeviceViewModel @Inject constructor( } } - is AuthRequestResult.Error -> { + is CreateAuthRequestResult.Error -> { mutableStateFlow.update { it.copy( viewState = LoginWithDeviceState.ViewState.Content( @@ -95,24 +114,51 @@ class LoginWithDeviceViewModel @Inject constructor( ) } } + + CreateAuthRequestResult.Declined -> { + mutableStateFlow.update { + it.copy( + viewState = LoginWithDeviceState.ViewState.Content( + fingerprintPhrase = "", + isResendNotificationLoading = false, + ), + dialogState = LoginWithDeviceState.DialogState.Error( + title = null, + message = R.string.this_request_is_no_longer_valid.asText(), + ), + ) + } + } + + CreateAuthRequestResult.Expired -> { + mutableStateFlow.update { + it.copy( + viewState = LoginWithDeviceState.ViewState.Content( + fingerprintPhrase = "", + isResendNotificationLoading = false, + ), + dialogState = LoginWithDeviceState.DialogState.Error( + title = null, + message = R.string.login_request_has_already_expired.asText(), + ), + ) + } + } } } - private fun sendNewAuthRequest() { - setIsResendNotificationLoading(true) - viewModelScope.launch { - trySendAction( - LoginWithDeviceAction.Internal.NewAuthRequestResultReceive( - result = authRepository.createAuthRequest( - email = state.emailAddress, - ), - ), - ) - } + private fun sendNewAuthRequest(isResend: Boolean) { + setIsResendNotificationLoading(isResend) + authJob.cancel() + authJob = authRepository + .createAuthRequestWithUpdates(email = state.emailAddress) + .map { LoginWithDeviceAction.Internal.NewAuthRequestResultReceive(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) } - private fun setIsResendNotificationLoading(isLoading: Boolean) { - updateContent { it.copy(isResendNotificationLoading = isLoading) } + private fun setIsResendNotificationLoading(isResend: Boolean) { + updateContent { it.copy(isResendNotificationLoading = isResend) } } private inline fun updateContent( @@ -225,7 +271,7 @@ sealed class LoginWithDeviceAction { * A new auth request result was received. */ data class NewAuthRequestResultReceive( - val result: AuthRequestResult, + val result: CreateAuthRequestResult, ) : Internal() } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModelTest.kt index 9c917dd86d..0b41524575 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModelTest.kt @@ -2,16 +2,18 @@ package com.x8bit.bitwarden.ui.auth.feature.loginwithdevice import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.bitwarden.core.AuthRequestResponse import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest -import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult +import com.x8bit.bitwarden.data.auth.repository.model.CreateAuthRequestResult +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.asText import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test @@ -19,35 +21,37 @@ import java.time.ZonedDateTime class LoginWithDeviceViewModelTest : BaseViewModelTest() { + private val mutableCreateAuthRequestWithUpdatesFlow = + bufferedMutableSharedFlow() private val authRepository = mockk { coEvery { - createAuthRequest(EMAIL) - } returns AuthRequestResult.Success(AUTH_REQUEST) + createAuthRequestWithUpdates(EMAIL) + } returns mutableCreateAuthRequestWithUpdatesFlow } @Test - fun `initial state should be correct`() = runTest { - val viewModel = createViewModel() - viewModel.stateFlow.test { - assertEquals(DEFAULT_STATE, awaitItem()) + fun `initial state should be correct`() { + val viewModel = createViewModel(state = null) + assertEquals( + DEFAULT_STATE.copy(viewState = LoginWithDeviceState.ViewState.Loading), + viewModel.stateFlow.value, + ) + coVerify(exactly = 1) { + authRepository.createAuthRequestWithUpdates(EMAIL) } - coVerify { authRepository.createAuthRequest(EMAIL) } } @Test - fun `initial state should be correct when set`() = runTest { + fun `initial state should be correct when set`() { val newEmail = "newEmail@gmail.com" - - coEvery { - authRepository.createAuthRequest(newEmail) - } returns AuthRequestResult.Success(AUTH_REQUEST) val state = DEFAULT_STATE.copy(emailAddress = newEmail) - val viewModel = createViewModel(state) - viewModel.stateFlow.test { - assertEquals(state, awaitItem()) - } - coVerify { - authRepository.createAuthRequest(newEmail) + coEvery { + authRepository.createAuthRequestWithUpdates(newEmail) + } returns mutableCreateAuthRequestWithUpdatesFlow + val viewModel = createViewModel(state = state) + assertEquals(state, viewModel.stateFlow.value) + coVerify(exactly = 1) { + authRepository.createAuthRequestWithUpdates(newEmail) } } @@ -64,7 +68,7 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() { } @Test - fun `DismissDialog should clear the dialog state`() = runTest { + fun `DismissDialog should clear the dialog state`() { val initialState = DEFAULT_STATE.copy( dialogState = LoginWithDeviceState.DialogState.Error( title = R.string.an_error_has_occurred.asText(), @@ -77,21 +81,20 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() { } @Test - fun `ResendNotificationClick should create new auth request and update state`() = runTest { - val newFingerprint = "newFingerprint" - coEvery { - authRepository.createAuthRequest(EMAIL) - } returns AuthRequestResult.Success(AUTH_REQUEST.copy(fingerprint = newFingerprint)) + fun `ResendNotificationClick should create new auth request and update state`() { val viewModel = createViewModel() viewModel.actionChannel.trySend(LoginWithDeviceAction.ResendNotificationClick) assertEquals( DEFAULT_STATE.copy( viewState = DEFAULT_CONTENT_VIEW_STATE.copy( - fingerprintPhrase = newFingerprint, + isResendNotificationLoading = true, ), ), viewModel.stateFlow.value, ) + verify(exactly = 2) { + authRepository.createAuthRequestWithUpdates(EMAIL) + } } @Test @@ -108,23 +111,16 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() { } @Test - fun `on auth request result success received should show content`() = runTest { - val newFingerprint = "newFingerprint" + fun `on createAuthRequestWithUpdates Update received should show content`() { val viewModel = createViewModel() assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) - viewModel.actionChannel.trySend( - LoginWithDeviceAction.Internal.NewAuthRequestResultReceive( - result = AuthRequestResult.Success( - authRequest = mockk { - every { fingerprint } returns newFingerprint - }, - ), - ), + mutableCreateAuthRequestWithUpdatesFlow.tryEmit( + CreateAuthRequestResult.Update(AUTH_REQUEST), ) assertEquals( DEFAULT_STATE.copy( viewState = DEFAULT_CONTENT_VIEW_STATE.copy( - fingerprintPhrase = newFingerprint, + fingerprintPhrase = FINGERPRINT, ), ), viewModel.stateFlow.value, @@ -132,17 +128,30 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() { } @Test - fun `on fingerprint result failure received should show error dialog`() = runTest { + fun `on createAuthRequestWithUpdates Success received should show content`() { val viewModel = createViewModel() assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) - viewModel.actionChannel.trySend( - LoginWithDeviceAction.Internal.NewAuthRequestResultReceive( - result = AuthRequestResult.Error, - ), + mutableCreateAuthRequestWithUpdatesFlow.tryEmit( + CreateAuthRequestResult.Success(AUTH_REQUEST, AUTH_REQUEST_RESPONSE), ) assertEquals( DEFAULT_STATE.copy( - viewState = LoginWithDeviceState.ViewState.Content( + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + fingerprintPhrase = "", + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on createAuthRequestWithUpdates Error received should show content with error dialog`() { + val viewModel = createViewModel() + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + mutableCreateAuthRequestWithUpdatesFlow.tryEmit(CreateAuthRequestResult.Error) + assertEquals( + DEFAULT_STATE.copy( + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( fingerprintPhrase = "", isResendNotificationLoading = false, ), @@ -155,12 +164,56 @@ class LoginWithDeviceViewModelTest : BaseViewModelTest() { ) } + @Suppress("MaxLineLength") + @Test + fun `on createAuthRequestWithUpdates Declined received should show content with error dialog`() { + val viewModel = createViewModel() + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + mutableCreateAuthRequestWithUpdatesFlow.tryEmit(CreateAuthRequestResult.Declined) + assertEquals( + DEFAULT_STATE.copy( + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + fingerprintPhrase = "", + isResendNotificationLoading = false, + ), + dialogState = LoginWithDeviceState.DialogState.Error( + title = null, + message = R.string.this_request_is_no_longer_valid.asText(), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on createAuthRequestWithUpdates Expired received should show content with error dialog`() { + val viewModel = createViewModel() + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + mutableCreateAuthRequestWithUpdatesFlow.tryEmit(CreateAuthRequestResult.Expired) + assertEquals( + DEFAULT_STATE.copy( + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + fingerprintPhrase = "", + isResendNotificationLoading = false, + ), + dialogState = LoginWithDeviceState.DialogState.Error( + title = null, + message = R.string.login_request_has_already_expired.asText(), + ), + ), + viewModel.stateFlow.value, + ) + } + private fun createViewModel( state: LoginWithDeviceState? = DEFAULT_STATE, ): LoginWithDeviceViewModel = LoginWithDeviceViewModel( authRepository = authRepository, - savedStateHandle = SavedStateHandle().apply { set("state", state) }, + savedStateHandle = SavedStateHandle().apply { + set("state", state) + set("email_address", state?.emailAddress ?: EMAIL) + }, ) } @@ -191,3 +244,10 @@ private val AUTH_REQUEST = AuthRequest( originUrl = "www.bitwarden.com", fingerprint = FINGERPRINT, ) + +private val AUTH_REQUEST_RESPONSE = AuthRequestResponse( + privateKey = "private_key", + publicKey = "public_key", + accessCode = "accessCode", + fingerprint = "fingerprint", +)