From 5ac5e31dd2c7ded5d1951d0f4c9bf6bb84663c3c Mon Sep 17 00:00:00 2001 From: Andrew Haisting <142518658+ahaisting-livefront@users.noreply.github.com> Date: Wed, 30 Oct 2024 15:12:54 -0500 Subject: [PATCH] BITAU-184 Allow user to save to Bitwarden when adding a code manually (#263) --- .../manualcodeentry/ManualCodeEntryScreen.kt | 21 +-- .../ManualCodeEntryViewModel.kt | 111 +++++++++++++- .../manualcodeentry/SaveManualCodeButtons.kt | 81 ++++++++++ app/src/main/res/values/strings.xml | 2 + .../ManualCodeEntryScreenTest.kt | 111 ++++++++++++++ .../ManualCodeEntryViewModelTest.kt | 143 ++++++++++++++++-- 6 files changed, 441 insertions(+), 28 deletions(-) create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/SaveManualCodeButtons.kt create mode 100644 app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreenTest.kt diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreen.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreen.kt index d8aff94ae1..9936e1d77d 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreen.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreen.kt @@ -37,7 +37,6 @@ import com.bitwarden.authenticator.R import com.bitwarden.authenticator.ui.platform.base.util.EventsEffect import com.bitwarden.authenticator.ui.platform.base.util.toAnnotatedString import com.bitwarden.authenticator.ui.platform.components.appbar.BitwardenTopAppBar -import com.bitwarden.authenticator.ui.platform.components.button.BitwardenFilledTonalButton import com.bitwarden.authenticator.ui.platform.components.dialog.BasicDialogState import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenBasicDialog import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenLoadingDialog @@ -201,17 +200,19 @@ fun ManualCodeEntryScreen( ) Spacer(modifier = Modifier.height(16.dp)) - BitwardenFilledTonalButton( - label = stringResource(id = R.string.add_code), - onClick = remember(viewModel) { - { viewModel.trySendAction(ManualCodeEntryAction.CodeSubmit) } + SaveManualCodeButtons( + state = state.buttonState, + onSaveLocallyClick = remember(viewModel) { + { + viewModel.trySendAction(ManualCodeEntryAction.SaveLocallyClick) + } + }, + onSaveToBitwardenClick = remember(viewModel) { + { + viewModel.trySendAction(ManualCodeEntryAction.SaveToBitwardenClick) + } }, - modifier = Modifier - .semantics { testTag = "AddCodeButton" } - .fillMaxWidth() - .padding(horizontal = 16.dp), ) - Text( text = stringResource(id = R.string.once_the_key_is_successfully_entered), style = MaterialTheme.typography.bodyMedium, diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModel.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModel.kt index 764dfcb9ab..7ef89f5120 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModel.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModel.kt @@ -8,10 +8,15 @@ import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.Aut import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState +import com.bitwarden.authenticator.data.authenticator.repository.util.isSyncWithBitwardenEnabled +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository import com.bitwarden.authenticator.ui.platform.base.BaseViewModel import com.bitwarden.authenticator.ui.platform.base.util.Text import com.bitwarden.authenticator.ui.platform.base.util.asText import com.bitwarden.authenticator.ui.platform.base.util.isBase32 +import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption +import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -26,24 +31,37 @@ private const val KEY_STATE = "state" * */ @HiltViewModel +@Suppress("TooManyFunctions") class ManualCodeEntryViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val authenticatorRepository: AuthenticatorRepository, + private val authenticatorBridgeManager: AuthenticatorBridgeManager, + settingsRepository: SettingsRepository, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] - ?: ManualCodeEntryState(code = "", issuer = "", dialog = null), + ?: ManualCodeEntryState( + code = "", + issuer = "", + dialog = null, + buttonState = deriveButtonState( + sharedCodesState = authenticatorRepository.sharedCodesStateFlow.value, + defaultSaveOption = settingsRepository.defaultSaveOption, + ), + ), ) { override fun handleAction(action: ManualCodeEntryAction) { when (action) { is ManualCodeEntryAction.CloseClick -> handleCloseClick() is ManualCodeEntryAction.CodeTextChange -> handleCodeTextChange(action) is ManualCodeEntryAction.IssuerTextChange -> handleIssuerTextChange(action) - is ManualCodeEntryAction.CodeSubmit -> handleCodeSubmit() is ManualCodeEntryAction.ScanQrCodeTextClick -> handleScanQrCodeTextClick() is ManualCodeEntryAction.SettingsClick -> handleSettingsClick() ManualCodeEntryAction.DismissDialog -> { handleDialogDismiss() } + + ManualCodeEntryAction.SaveLocallyClick -> handleSaveLocallyClick() + ManualCodeEntryAction.SaveToBitwardenClick -> handleSaveToBitwardenClick() } } @@ -67,7 +85,11 @@ class ManualCodeEntryViewModel @Inject constructor( } } - private fun handleCodeSubmit() { + private fun handleSaveLocallyClick() = handleCodeSubmit(saveToBitwarden = false) + + private fun handleSaveToBitwardenClick() = handleCodeSubmit(saveToBitwarden = true) + + private fun handleCodeSubmit(saveToBitwarden: Boolean) { val isSteamCode = state.code.startsWith(TotpCodeManager.STEAM_CODE_PREFIX) val sanitizedCode = state.code .replace(" ", "") @@ -87,6 +109,38 @@ class ManualCodeEntryViewModel @Inject constructor( return } + if (saveToBitwarden) { + // Save to Bitwarden by kicking off save to Bitwarden flow: + saveValidCodeToBitwarden(sanitizedCode) + } else { + // Save locally by giving entity to AuthRepository and navigating back: + saveValidCodeLocally(sanitizedCode, isSteamCode) + } + } + + private fun saveValidCodeToBitwarden(sanitizedCode: String) { + val didLaunchSaveToBitwarden = authenticatorBridgeManager + .startAddTotpLoginItemFlow( + totpUri = "otpauth://totp/?secret=$sanitizedCode&issuer=${state.issuer}", + ) + if (!didLaunchSaveToBitwarden) { + mutableStateFlow.update { + it.copy( + dialog = ManualCodeEntryState.DialogState.Error( + title = R.string.something_went_wrong.asText(), + message = R.string.please_try_again.asText(), + ), + ) + } + } else { + sendEvent(ManualCodeEntryEvent.NavigateBack) + } + } + + private fun saveValidCodeLocally( + sanitizedCode: String, + isSteamCode: Boolean, + ) { viewModelScope.launch { authenticatorRepository.createItem( AuthenticatorItemEntity( @@ -133,6 +187,22 @@ class ManualCodeEntryViewModel @Inject constructor( } } +private fun deriveButtonState( + sharedCodesState: SharedVerificationCodesState, + defaultSaveOption: DefaultSaveOption, +): ManualCodeEntryState.ButtonState { + // If syncing with Bitwarden is not enabled, show local save only: + if (!sharedCodesState.isSyncWithBitwardenEnabled) { + return ManualCodeEntryState.ButtonState.LocalOnly + } + // Otherwise, show save options based on user's preferences: + return when (defaultSaveOption) { + DefaultSaveOption.NONE -> ManualCodeEntryState.ButtonState.SaveToBitwardenPrimary + DefaultSaveOption.BITWARDEN_APP -> ManualCodeEntryState.ButtonState.SaveToBitwardenPrimary + DefaultSaveOption.LOCAL -> ManualCodeEntryState.ButtonState.SaveLocallyPrimary + } +} + /** * Models state of the manual entry screen. */ @@ -141,6 +211,7 @@ data class ManualCodeEntryState( val code: String, val issuer: String, val dialog: DialogState?, + val buttonState: ButtonState, ) : Parcelable { /** @@ -166,6 +237,31 @@ data class ManualCodeEntryState( val message: Text, ) : DialogState() } + + /** + * Models what variation of button states should be shown. + */ + @Parcelize + sealed class ButtonState : Parcelable { + + /** + * Show only save locally option. + */ + @Parcelize + data object LocalOnly : ButtonState() + + /** + * Show both save locally and save to Bitwarden, with Bitwarden being the primary option. + */ + @Parcelize + data object SaveToBitwardenPrimary : ButtonState() + + /** + * Show both save locally and save to Bitwarden, with locally being the primary option. + */ + @Parcelize + data object SaveLocallyPrimary : ButtonState() + } } /** @@ -205,9 +301,14 @@ sealed class ManualCodeEntryAction { data object CloseClick : ManualCodeEntryAction() /** - * The user has submitted a code. + * The user clicked the save locally button. */ - data object CodeSubmit : ManualCodeEntryAction() + data object SaveLocallyClick : ManualCodeEntryAction() + + /** + * Th user clicked the save to Bitwarden button. + */ + data object SaveToBitwardenClick : ManualCodeEntryAction() /** * The user has changed the code text. diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/SaveManualCodeButtons.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/SaveManualCodeButtons.kt new file mode 100644 index 0000000000..974ec424cf --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/SaveManualCodeButtons.kt @@ -0,0 +1,81 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.manualcodeentry + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.unit.dp +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.components.button.BitwardenFilledButton +import com.bitwarden.authenticator.ui.platform.components.button.BitwardenFilledTonalButton +import com.bitwarden.authenticator.ui.platform.components.button.BitwardenOutlinedButton + +/** + * Displays save buttons for saving a manually entered code. + * + * @param state State of the buttons to show. + * @param onSaveLocallyClick Callback invoked when the user clicks save locally. + * @param onSaveToBitwardenClick Callback invoked when the user clicks save to Bitwarden. + */ +@Composable +fun SaveManualCodeButtons( + state: ManualCodeEntryState.ButtonState, + onSaveLocallyClick: () -> Unit, + onSaveToBitwardenClick: () -> Unit, +) { + + when (state) { + ManualCodeEntryState.ButtonState.LocalOnly -> { + BitwardenFilledTonalButton( + label = stringResource(id = R.string.add_code), + onClick = onSaveLocallyClick, + modifier = Modifier + .semantics { testTag = "AddCodeButton" } + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + + ManualCodeEntryState.ButtonState.SaveLocallyPrimary -> { + Column { + BitwardenFilledButton( + label = stringResource(id = R.string.add_code_locally), + onClick = onSaveLocallyClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + BitwardenOutlinedButton( + label = stringResource(R.string.add_code_to_bitwarden), + onClick = onSaveToBitwardenClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + + ManualCodeEntryState.ButtonState.SaveToBitwardenPrimary -> { + Column { + BitwardenFilledButton( + label = stringResource(id = R.string.add_code_to_bitwarden), + onClick = onSaveToBitwardenClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + BitwardenOutlinedButton( + label = stringResource(R.string.add_code_locally), + onClick = onSaveLocallyClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 57447fa511..702c7eacce 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -145,4 +145,6 @@ Save this authenticator key here, or add it to a login in your Bitwarden app. Save option as default Account synced from Bitwarden app + Add code to Bitwarden + Add code locally diff --git a/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreenTest.kt b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreenTest.kt new file mode 100644 index 0000000000..78e2838e81 --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreenTest.kt @@ -0,0 +1,111 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.manualcodeentry + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow +import com.bitwarden.authenticator.ui.platform.base.BaseComposeTest +import com.bitwarden.authenticator.ui.platform.manager.intent.IntentManager +import com.bitwarden.authenticator.ui.platform.manager.permissions.FakePermissionManager +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.Before +import org.junit.Test + +class ManualCodeEntryScreenTest : BaseComposeTest() { + + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val mutableEventFlow = bufferedMutableSharedFlow() + + private val viewModel: ManualCodeEntryViewModel = mockk { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns mutableEventFlow + every { trySendAction(any()) } just runs + } + + private val intentManager: IntentManager = mockk() + private val permissionsManager = FakePermissionManager() + + @Before + fun setup() { + composeTestRule.setContent { + ManualCodeEntryScreen( + onNavigateBack = {}, + onNavigateToQrCodeScreen = {}, + viewModel = viewModel, + intentManager = intentManager, + permissionsManager = permissionsManager, + ) + } + } + + @Test + fun `on Add code click should emit SaveLocallyClick`() { + composeTestRule + .onNodeWithText("Add code") + .performClick() + + // Make sure save to bitwaren isn't showing: + composeTestRule + .onNodeWithText("Add code to Bitwarden") + .assertDoesNotExist() + + verify { viewModel.trySendAction(ManualCodeEntryAction.SaveLocallyClick) } + } + + @Test + fun `on Add code to Bitwarden click should emit SaveToBitwardenClick`() { + mutableStateFlow.update { + it.copy(buttonState = ManualCodeEntryState.ButtonState.SaveToBitwardenPrimary) + } + composeTestRule + .onNodeWithText("Add code to Bitwarden") + .performClick() + + // Make sure locally only save isn't showing: + composeTestRule + .onNodeWithText("Add code") + .assertDoesNotExist() + + // Make sure locally option is showing: + composeTestRule + .onNodeWithText("Add code locally") + .assertIsDisplayed() + + verify { viewModel.trySendAction(ManualCodeEntryAction.SaveToBitwardenClick) } + } + + @Test + fun `on Add code locally click should emit SaveLocallyClick`() { + mutableStateFlow.update { + it.copy(buttonState = ManualCodeEntryState.ButtonState.SaveLocallyPrimary) + } + composeTestRule + .onNodeWithText("Add code locally") + .performClick() + + // Make sure locally only save isn't showing: + composeTestRule + .onNodeWithText("Add code") + .assertDoesNotExist() + + // Make sure save to bitwarden option is showing: + composeTestRule + .onNodeWithText("Add code to Bitwarden") + .assertIsDisplayed() + + verify { viewModel.trySendAction(ManualCodeEntryAction.SaveLocallyClick) } + } +} + +private val DEFAULT_STATE = ManualCodeEntryState( + code = "", + issuer = "", + dialog = null, + buttonState = ManualCodeEntryState.ButtonState.LocalOnly, +) diff --git a/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModelTest.kt b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModelTest.kt index 416f5e86c3..56304d23b2 100644 --- a/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModelTest.kt +++ b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModelTest.kt @@ -7,14 +7,20 @@ import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.Aut import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository import com.bitwarden.authenticator.data.authenticator.repository.model.CreateItemResult +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository import com.bitwarden.authenticator.ui.platform.base.BaseViewModelTest import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption +import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -24,7 +30,14 @@ import java.util.UUID class ManualCodeEntryViewModelTest : BaseViewModelTest() { - private val mockAuthenticatorRepository = mockk() + private val mockAuthenticatorRepository = mockk { + every { sharedCodesStateFlow } returns + MutableStateFlow(SharedVerificationCodesState.SyncNotEnabled) + } + private val mockSettingRepository = mockk { + every { defaultSaveOption } returns DefaultSaveOption.NONE + } + private val mockAuthenticatorBridgeManager = mockk() @BeforeEach fun setUp() { @@ -43,6 +56,7 @@ class ManualCodeEntryViewModelTest : BaseViewModelTest() { code = "ABCD", issuer = "mockIssuer", dialog = null, + buttonState = ManualCodeEntryState.ButtonState.LocalOnly, ) val viewModel = createViewModel(initialState = initialState) assertEquals(initialState, viewModel.stateFlow.value) @@ -54,6 +68,57 @@ class ManualCodeEntryViewModelTest : BaseViewModelTest() { assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) } + @Test + @Suppress("MaxLineLength") + fun `initial button state should be SaveToBitwardenPrimary when sync is enabled and default save option is BITWARDEN_APP`() { + every { + mockAuthenticatorRepository.sharedCodesStateFlow + } returns MutableStateFlow(SharedVerificationCodesState.Success(emptyList())) + every { mockSettingRepository.defaultSaveOption } returns DefaultSaveOption.BITWARDEN_APP + + val viewModel = createViewModel(initialState = null) + + val expectedState = DEFAULT_STATE.copy( + buttonState = ManualCodeEntryState.ButtonState.SaveToBitwardenPrimary, + ) + verify { mockSettingRepository.defaultSaveOption } + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `initial button state should be SaveLocallyPrimary when sync is enabled and default save option is LOCAL`() { + every { + mockAuthenticatorRepository.sharedCodesStateFlow + } returns MutableStateFlow(SharedVerificationCodesState.Success(emptyList())) + every { mockSettingRepository.defaultSaveOption } returns DefaultSaveOption.LOCAL + + val viewModel = createViewModel(initialState = null) + + val expectedState = DEFAULT_STATE.copy( + buttonState = ManualCodeEntryState.ButtonState.SaveLocallyPrimary, + ) + verify { mockSettingRepository.defaultSaveOption } + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `initial button state should be SaveLocallyPrimary when sync is enabled and default save option is NONE`() { + every { + mockAuthenticatorRepository.sharedCodesStateFlow + } returns MutableStateFlow(SharedVerificationCodesState.Success(emptyList())) + every { mockSettingRepository.defaultSaveOption } returns DefaultSaveOption.NONE + + val viewModel = createViewModel(initialState = null) + + val expectedState = DEFAULT_STATE.copy( + buttonState = ManualCodeEntryState.ButtonState.SaveToBitwardenPrimary, + ) + verify { mockSettingRepository.defaultSaveOption } + assertEquals(expectedState, viewModel.stateFlow.value) + } + @Test fun `CloseClick should navigate back`() = runTest { val viewModel = createViewModel() @@ -88,7 +153,7 @@ class ManualCodeEntryViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `CodeSubmit should createItem, show toast, and navigate back on success when code is valid`() = + fun `SaveLocallyClick should createItem, show toast, and navigate back on success when code is valid`() = runTest { coEvery { mockAuthenticatorRepository.createItem( @@ -109,7 +174,7 @@ class ManualCodeEntryViewModelTest : BaseViewModelTest() { .copy(code = "ABCD", issuer = "mockIssuer"), ) - viewModel.trySendAction(ManualCodeEntryAction.CodeSubmit) + viewModel.trySendAction(ManualCodeEntryAction.SaveLocallyClick) coVerify { mockAuthenticatorRepository.createItem( @@ -136,8 +201,57 @@ class ManualCodeEntryViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") @Test - fun `CodeSubmit should replace whitespace from code`() = runTest { + fun `SaveToBitwardenClick should launch add to Bitwarden flow and navigate back on success when code is valid`() = + runTest { + val expectedUri = "otpauth://totp/?secret=ABCD&issuer=mockIssuer" + every { + mockAuthenticatorBridgeManager.startAddTotpLoginItemFlow(expectedUri) + } returns true + val viewModel = createViewModel( + initialState = DEFAULT_STATE + .copy(code = "ABCD", issuer = "mockIssuer"), + ) + viewModel.trySendAction(ManualCodeEntryAction.SaveToBitwardenClick) + verify { + mockAuthenticatorBridgeManager.startAddTotpLoginItemFlow(expectedUri) + } + viewModel.eventFlow.test { + assertEquals( + ManualCodeEntryEvent.NavigateBack, + awaitItem(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `SaveToBitwardenClick should show error when code is valid but startAddTotpLoginItemFlow fails`() = + runTest { + val expectedUri = "otpauth://totp/?secret=ABCD&issuer=mockIssuer" + every { + mockAuthenticatorBridgeManager.startAddTotpLoginItemFlow(expectedUri) + } returns false + val viewModel = createViewModel( + initialState = DEFAULT_STATE + .copy(code = "ABCD", issuer = "mockIssuer"), + ) + viewModel.trySendAction(ManualCodeEntryAction.SaveToBitwardenClick) + verify { mockAuthenticatorBridgeManager.startAddTotpLoginItemFlow(expectedUri) } + val expectedState = DEFAULT_STATE.copy( + code = "ABCD", + issuer = "mockIssuer", + dialog = ManualCodeEntryState.DialogState.Error( + title = R.string.something_went_wrong.asText(), + message = R.string.please_try_again.asText(), + ), + ) + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + fun `SaveLocallyClick should replace whitespace from code`() = runTest { coEvery { mockAuthenticatorRepository.createItem( item = AuthenticatorItemEntity( @@ -159,7 +273,7 @@ class ManualCodeEntryViewModelTest : BaseViewModelTest() { ), ) - viewModel.trySendAction(ManualCodeEntryAction.CodeSubmit) + viewModel.trySendAction(ManualCodeEntryAction.SaveLocallyClick) coVerify { mockAuthenticatorRepository.createItem( @@ -177,14 +291,14 @@ class ManualCodeEntryViewModelTest : BaseViewModelTest() { } @Test - fun `CodeSubmit should show error dialog when code is empty`() = runTest { + fun `SaveLocallyClick should show error dialog when code is empty`() = runTest { val viewModel = createViewModel( initialState = DEFAULT_STATE.copy( code = " ", ), ) - viewModel.trySendAction(ManualCodeEntryAction.CodeSubmit) + viewModel.trySendAction(ManualCodeEntryAction.SaveLocallyClick) assertEquals( ManualCodeEntryState.DialogState.Error( @@ -195,14 +309,14 @@ class ManualCodeEntryViewModelTest : BaseViewModelTest() { } @Test - fun `CodeSubmit should show error dialog when code is not base32`() { + fun `SaveLocallyClick should show error dialog when code is not base32`() { val viewModel = createViewModel( initialState = DEFAULT_STATE.copy( code = "ABCD12345", ), ) - viewModel.trySendAction(ManualCodeEntryAction.CodeSubmit) + viewModel.trySendAction(ManualCodeEntryAction.SaveLocallyClick) assertEquals( ManualCodeEntryState.DialogState.Error( @@ -213,7 +327,7 @@ class ManualCodeEntryViewModelTest : BaseViewModelTest() { } @Test - fun `CodeSubmit should show error dialog when issuer is empty`() { + fun `SaveLocallyClick should show error dialog when issuer is empty`() { val viewModel = createViewModel( initialState = DEFAULT_STATE.copy( code = "ABCD", @@ -221,7 +335,7 @@ class ManualCodeEntryViewModelTest : BaseViewModelTest() { ), ) - viewModel.trySendAction(ManualCodeEntryAction.CodeSubmit) + viewModel.trySendAction(ManualCodeEntryAction.SaveLocallyClick) assertEquals( ManualCodeEntryState.DialogState.Error( @@ -233,7 +347,7 @@ class ManualCodeEntryViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `CodeSubmit should set AuthenticatorItemType to STEAM when code starts with steam protocol`() { + fun `SaveLocallyClick should set AuthenticatorItemType to STEAM when code starts with steam protocol`() { coEvery { mockAuthenticatorRepository.createItem( item = AuthenticatorItemEntity( @@ -255,7 +369,7 @@ class ManualCodeEntryViewModelTest : BaseViewModelTest() { ), ) - viewModel.trySendAction(ManualCodeEntryAction.CodeSubmit) + viewModel.trySendAction(ManualCodeEntryAction.SaveLocallyClick) coVerify { mockAuthenticatorRepository.createItem( @@ -318,6 +432,8 @@ class ManualCodeEntryViewModelTest : BaseViewModelTest() { ManualCodeEntryViewModel( savedStateHandle = SavedStateHandle().apply { set("state", initialState) }, authenticatorRepository = mockAuthenticatorRepository, + authenticatorBridgeManager = mockAuthenticatorBridgeManager, + settingsRepository = mockSettingRepository, ) } @@ -326,4 +442,5 @@ private val DEFAULT_STATE: ManualCodeEntryState = code = "", issuer = "", dialog = null, + buttonState = ManualCodeEntryState.ButtonState.LocalOnly, )