From e7450171cd61a6972ef49a61fc6d2a118c188d16 Mon Sep 17 00:00:00 2001 From: David Perez Date: Tue, 8 Oct 2024 10:10:18 -0500 Subject: [PATCH] This PR adds the TOTP matching flow to the app (#4042) --- app/src/main/AndroidManifest.xml | 9 ++ .../feature/addedit/VaultAddEditViewModel.kt | 104 +++++++++--------- .../addedit/util/CipherViewExtensions.kt | 4 +- .../addedit/util/TotpDataExtensions.kt | 18 +++ .../itemlisting/VaultItemListingContent.kt | 18 +++ .../itemlisting/VaultItemListingScreen.kt | 16 ++- .../itemlisting/VaultItemListingViewModel.kt | 52 ++++++++- app/src/main/res/values/strings.xml | 1 + .../addedit/VaultAddEditViewModelTest.kt | 68 +++++++++++- .../addedit/util/CipherViewExtensionsTest.kt | 65 +++++++++++ .../addedit/util/TotpDataExtensionsTest.kt | 48 ++++++++ .../itemlisting/VaultItemListingScreenTest.kt | 24 +++- .../VaultItemListingViewModelTest.kt | 78 ++++++++++++- .../util/VaultItemListingDataUtil.kt | 11 +- 14 files changed, 449 insertions(+), 67 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/TotpDataExtensions.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/TotpDataExtensionsTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1ee497730..e36381a54 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -89,6 +89,15 @@ + + + + + + + + + + fido2CredentialManager.getPasskeyAttestationOptionsOrNull(request.requestJson) + } - val fido2CreationRequest = specialCircumstanceManager - .specialCircumstance - ?.toFido2RequestOrNull() + // Exit on save if handling an autofill, Fido2 Attestation, or TOTP link + val shouldExitOnSave = autofillSaveItem != null || + fido2AttestationOptions != null || + totpData != null - val fido2AttestationOptions = fido2CreationRequest - ?.let { request -> - fido2CredentialManager - .getPasskeyAttestationOptionsOrNull(request.requestJson) - } - - val dialogState = - if (!settingsRepository.initialAutofillDialogShown && - vaultAddEditType is VaultAddEditType.AddItem && - autofillSelectionData == null - ) { - VaultAddEditState.DialogState.InitialAutofillPrompt - } else { - null - } + val dialogState = if (!settingsRepository.initialAutofillDialogShown && + vaultAddEditType is VaultAddEditType.AddItem && + autofillSelectionData == null + ) { + VaultAddEditState.DialogState.InitialAutofillPrompt + } else { + null + } VaultAddEditState( vaultAddEditType = vaultAddEditType, @@ -142,13 +142,12 @@ class VaultAddEditViewModel @Inject constructor( is VaultAddEditType.AddItem -> { autofillSelectionData ?.toDefaultAddTypeContent(isIndividualVaultDisabled) - ?: autofillSaveItem - ?.toDefaultAddTypeContent(isIndividualVaultDisabled) - ?: fido2CreationRequest - ?.toDefaultAddTypeContent( - attestationOptions = fido2AttestationOptions, - isIndividualVaultDisabled = isIndividualVaultDisabled, - ) + ?: autofillSaveItem?.toDefaultAddTypeContent(isIndividualVaultDisabled) + ?: fido2CreationRequest?.toDefaultAddTypeContent( + attestationOptions = fido2AttestationOptions, + isIndividualVaultDisabled = isIndividualVaultDisabled, + ) + ?: totpData?.toDefaultAddTypeContent(isIndividualVaultDisabled) ?: VaultAddEditState.ViewState.Content( common = VaultAddEditState.ViewState.Content.Common(), isIndividualVaultDisabled = isIndividualVaultDisabled, @@ -160,9 +159,10 @@ class VaultAddEditViewModel @Inject constructor( is VaultAddEditType.CloneItem -> VaultAddEditState.ViewState.Loading }, dialog = dialogState, + totpData = totpData, // Set special conditions for autofill and fido2 save shouldShowCloseButton = autofillSaveItem == null && fido2AttestationOptions == null, - shouldExitOnSave = autofillSaveItem != null || fido2AttestationOptions != null, + shouldExitOnSave = shouldExitOnSave, ) }, ) { @@ -1412,18 +1412,14 @@ class VaultAddEditViewModel @Inject constructor( } is CreateCipherResult.Success -> { + specialCircumstanceManager.specialCircumstance = null if (state.shouldExitOnSave) { - specialCircumstanceManager.specialCircumstance = null - sendEvent( - event = VaultAddEditEvent.ExitApp, - ) + sendEvent(event = VaultAddEditEvent.ExitApp) } else { sendEvent( event = VaultAddEditEvent.ShowToast(R.string.new_item_created.asText()), ) - sendEvent( - event = VaultAddEditEvent.NavigateBack, - ) + sendEvent(event = VaultAddEditEvent.NavigateBack) } } } @@ -1444,10 +1440,13 @@ class VaultAddEditViewModel @Inject constructor( } is UpdateCipherResult.Success -> { - sendEvent( - event = VaultAddEditEvent.ShowToast(R.string.item_updated.asText()), - ) - sendEvent(VaultAddEditEvent.NavigateBack) + specialCircumstanceManager.specialCircumstance = null + if (state.shouldExitOnSave) { + sendEvent(event = VaultAddEditEvent.ExitApp) + } else { + sendEvent(event = VaultAddEditEvent.ShowToast(R.string.item_updated.asText())) + sendEvent(event = VaultAddEditEvent.NavigateBack) + } } } } @@ -1544,15 +1543,19 @@ class VaultAddEditViewModel @Inject constructor( ) { currentAccount, cipherView -> // Derive the view state from the current Cipher for Edit mode // or use the current state for Add - (cipherView?.toViewState( - isClone = isCloneMode, - isIndividualVaultDisabled = isIndividualVaultDisabled, - resourceManager = resourceManager, - clock = clock, - ) ?: viewState) + (cipherView + ?.toViewState( + isClone = isCloneMode, + isIndividualVaultDisabled = isIndividualVaultDisabled, + totpData = totpData, + resourceManager = resourceManager, + clock = clock, + ) + ?: viewState) .appendFolderAndOwnerData( folderViewList = vaultData.folderViewList, - collectionViewList = vaultData.collectionViewList + collectionViewList = vaultData + .collectionViewList .filter { !it.readOnly }, activeAccount = currentAccount, isIndividualVaultDisabled = isIndividualVaultDisabled, @@ -1911,6 +1914,7 @@ data class VaultAddEditState( val shouldShowCloseButton: Boolean = true, // Internal val shouldExitOnSave: Boolean = false, + val totpData: TotpData? = null, ) : Parcelable { /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt index 49251729d..62af2170e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt @@ -18,6 +18,7 @@ import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditState import com.x8bit.bitwarden.ui.vault.feature.addedit.model.UriItem +import com.x8bit.bitwarden.ui.vault.model.TotpData import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand import com.x8bit.bitwarden.ui.vault.model.VaultCardExpirationMonth @@ -37,6 +38,7 @@ private const val PASSKEY_CREATION_TIME_PATTERN: String = "hh:mm a" fun CipherView.toViewState( isClone: Boolean, isIndividualVaultDisabled: Boolean, + totpData: TotpData?, resourceManager: ResourceManager, clock: Clock, ): VaultAddEditState.ViewState = @@ -46,7 +48,7 @@ fun CipherView.toViewState( VaultAddEditState.ViewState.Content.ItemType.Login( username = login?.username.orEmpty(), password = login?.password.orEmpty(), - totp = login?.totp, + totp = totpData?.uri ?: login?.totp, canViewPassword = this.viewPassword, canEditItem = this.edit, uriList = login?.uris.toUriItems(), diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/TotpDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/TotpDataExtensions.kt new file mode 100644 index 000000000..8f11ec8fd --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/TotpDataExtensions.kt @@ -0,0 +1,18 @@ +package com.x8bit.bitwarden.ui.vault.feature.addedit.util + +import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditState +import com.x8bit.bitwarden.ui.vault.model.TotpData + +/** + * Returns pre-filled content that may be used for an "add" type + * [VaultAddEditState.ViewState.Content] during a TOTP creation event. + */ +fun TotpData.toDefaultAddTypeContent( + isIndividualVaultDisabled: Boolean, +): VaultAddEditState.ViewState.Content = VaultAddEditState.ViewState.Content( + common = VaultAddEditState.ViewState.Content.Common( + name = (this.issuer ?: this.accountName).orEmpty(), + ), + isIndividualVaultDisabled = isIndividualVaultDisabled, + type = VaultAddEditState.ViewState.Content.ItemType.Login(totp = this.uri), +) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt index f19835d54..0a0a60073 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenMasterPasswordDialog @@ -27,6 +28,7 @@ import com.x8bit.bitwarden.ui.platform.components.listitem.SelectionItemData import com.x8bit.bitwarden.ui.platform.components.model.toIconResources import com.x8bit.bitwarden.ui.platform.components.text.BitwardenPolicyWarningText import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter +import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction import kotlinx.collections.immutable.toPersistentList @@ -38,6 +40,7 @@ import kotlinx.collections.immutable.toPersistentList fun VaultItemListingContent( state: VaultItemListingState.ViewState.Content, policyDisablesSend: Boolean, + showAddTotpBanner: Boolean, collectionClick: (id: String) -> Unit, folderClick: (id: String) -> Unit, vaultItemClick: (id: String) -> Unit, @@ -100,8 +103,23 @@ fun VaultItemListingContent( LazyColumn( modifier = modifier, ) { + item { + if (showAddTotpBanner) { + Spacer(modifier = Modifier.height(height = 12.dp)) + BitwardenPolicyWarningText( + text = stringResource(id = R.string.add_this_authenticator_key_to_a_login), + style = BitwardenTheme.typography.bodyMedium, + textAlign = TextAlign.Start, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + } + } + item { if (policyDisablesSend) { + Spacer(modifier = Modifier.height(height = 12.dp)) BitwardenPolicyWarningText( text = stringResource(id = R.string.send_disabled_warning), modifier = Modifier diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt index 908989fea..082f0aca7 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.ui.vault.feature.itemlisting import android.widget.Toast +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api @@ -45,11 +46,13 @@ import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.scaffold.rememberBitwardenPullToRefreshState import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter import com.x8bit.bitwarden.ui.platform.composition.LocalBiometricsManager +import com.x8bit.bitwarden.ui.platform.composition.LocalExitManager import com.x8bit.bitwarden.ui.platform.composition.LocalFido2CompletionManager import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.PinInputDialog import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager +import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.vault.feature.itemlisting.handlers.VaultItemListingHandlers import com.x8bit.bitwarden.ui.vault.feature.itemlisting.handlers.VaultItemListingUserVerificationHandlers @@ -74,6 +77,7 @@ fun VaultItemListingScreen( onNavigateToEditSendItem: (sendId: String) -> Unit, onNavigateToSearch: (searchType: SearchType) -> Unit, intentManager: IntentManager = LocalIntentManager.current, + exitManager: ExitManager = LocalExitManager.current, fido2CompletionManager: Fido2CompletionManager = LocalFido2CompletionManager.current, biometricsManager: BiometricsManager = LocalBiometricsManager.current, viewModel: VaultItemListingViewModel = hiltViewModel(), @@ -168,6 +172,8 @@ fun VaultItemListingScreen( is VaultItemListingEvent.CompleteFido2GetCredentialsRequest -> { fido2CompletionManager.completeFido2GetCredentialRequest(event.result) } + + VaultItemListingEvent.ExitApp -> exitManager.exitApplication() } } @@ -252,12 +258,15 @@ fun VaultItemListingScreen( }, ) + val vaultItemListingHandlers = remember(viewModel) { + VaultItemListingHandlers.create(viewModel) + } + + BackHandler(onBack = vaultItemListingHandlers.backClick) VaultItemListingScaffold( state = state, pullToRefreshState = pullToRefreshState, - vaultItemListingHandlers = remember(viewModel) { - VaultItemListingHandlers.create(viewModel) - }, + vaultItemListingHandlers = vaultItemListingHandlers, ) } @@ -451,6 +460,7 @@ private fun VaultItemListingScaffold( is VaultItemListingState.ViewState.Content -> { VaultItemListingContent( state = state.viewState, + showAddTotpBanner = state.isTotp, policyDisablesSend = state.policyDisablesSend && state.itemListingType is VaultItemListingState.ItemListingType.Send, vaultItemClick = vaultItemListingHandlers.itemClick, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index 4235716f2..862ab5163 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -33,6 +33,7 @@ import com.x8bit.bitwarden.data.platform.manager.util.toAutofillSelectionDataOrN import com.x8bit.bitwarden.data.platform.manager.util.toFido2AssertionRequestOrNull import com.x8bit.bitwarden.data.platform.manager.util.toFido2GetCredentialsRequestOrNull import com.x8bit.bitwarden.data.platform.manager.util.toFido2RequestOrNull +import com.x8bit.bitwarden.data.platform.manager.util.toTotpDataOrNull import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.model.DataState @@ -57,7 +58,9 @@ import com.x8bit.bitwarden.ui.platform.base.util.toHostOrPathOrNull import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary import com.x8bit.bitwarden.ui.platform.components.model.IconData import com.x8bit.bitwarden.ui.platform.components.model.IconRes +import com.x8bit.bitwarden.ui.platform.feature.search.SearchTypeData import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType +import com.x8bit.bitwarden.ui.platform.feature.search.util.filterAndOrganize import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.determineListingPredicate import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.toItemListingType @@ -69,6 +72,7 @@ import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries import com.x8bit.bitwarden.ui.vault.feature.vault.util.toActiveAccountSummary import com.x8bit.bitwarden.ui.vault.feature.vault.util.toFilteredList +import com.x8bit.bitwarden.ui.vault.model.TotpData import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn @@ -127,6 +131,7 @@ class VaultItemListingViewModel @Inject constructor( .any(), autofillSelectionData = specialCircumstance?.toAutofillSelectionDataOrNull(), hasMasterPassword = userState.activeAccount.hasMasterPassword, + totpData = specialCircumstance?.toTotpDataOrNull(), fido2CredentialRequest = fido2CredentialRequest, fido2CredentialAssertionRequest = specialCircumstance?.toFido2AssertionRequestOrNull(), fido2GetCredentialsRequest = specialCircumstance?.toFido2GetCredentialsRequestOrNull(), @@ -184,7 +189,8 @@ class VaultItemListingViewModel @Inject constructor( it .filterForAutofillIfNecessary() .filterForFido2CreationIfNecessary() - .filterForFidoGetCredentialsIfNecessary(), + .filterForFidoGetCredentialsIfNecessary() + .filterForTotpIfNecessary(), ) } .onEach(::sendAction) @@ -560,6 +566,10 @@ class VaultItemListingViewModel @Inject constructor( } return } + state.totpData?.let { + sendEvent(VaultItemListingEvent.NavigateToEditCipher(cipherId = action.id)) + return + } if (state.isFido2Creation) { handleFido2RegistrationRequestReceive(action) @@ -832,7 +842,11 @@ class VaultItemListingViewModel @Inject constructor( private fun handleBackClick() { sendEvent( - event = VaultItemListingEvent.NavigateBack, + event = if (state.isTotp) { + VaultItemListingEvent.ExitApp + } else { + VaultItemListingEvent.NavigateBack + }, ) } @@ -1639,6 +1653,22 @@ class VaultItemListingViewModel @Inject constructor( } } + /** + * Takes the given vault data and filters it for totp data. + */ + private fun DataState.filterForTotpIfNecessary(): DataState { + val totpData = state.totpData ?: return this + val query = totpData.issuer ?: totpData.accountName ?: return this + return this.map { vaultData -> + vaultData.copy( + cipherViewList = vaultData.cipherViewList.filterAndOrganize( + searchTypeData = SearchTypeData.Vault.Logins, + searchTerm = query, + ), + ) + } + } + /** * Decrypt and filter the fido 2 autofill credentials. */ @@ -1687,6 +1717,7 @@ data class VaultItemListingState( val policyDisablesSend: Boolean, // Internal private val isPullToRefreshSettingEnabled: Boolean, + val totpData: TotpData? = null, val autofillSelectionData: AutofillSelectionData? = null, val fido2CredentialRequest: Fido2CredentialRequest? = null, val fido2CredentialAssertionRequest: Fido2CredentialAssertionRequest? = null, @@ -1707,6 +1738,11 @@ data class VaultItemListingState( val isFido2Creation: Boolean get() = fido2CredentialRequest != null + /** + * Whether or not this represents a listing screen for totp. + */ + val isTotp: Boolean get() = totpData != null + /** * A displayable title for the AppBar. */ @@ -1719,6 +1755,7 @@ data class VaultItemListingState( ?.callingAppInfo ?.getFido2RpIdOrNull() ?.let { R.string.items_for_uri.asText(it) } + ?: totpData?.let { R.string.items_for_uri.asText(it.issuer ?: it.accountName ?: "--") } ?: itemListingType.titleText /** @@ -1730,17 +1767,17 @@ data class VaultItemListingState( /** * Whether or not the account switcher should be shown. */ - val shouldShowAccountSwitcher: Boolean get() = isAutofill || isFido2Creation + val shouldShowAccountSwitcher: Boolean get() = isAutofill || isFido2Creation || isTotp /** * Whether or not the navigation icon should be shown. */ - val shouldShowNavigationIcon: Boolean get() = !isAutofill && !isFido2Creation + val shouldShowNavigationIcon: Boolean get() = !isAutofill && !isFido2Creation && !isTotp /** * Whether or not the overflow menu should be shown. */ - val shouldShowOverflowMenu: Boolean get() = !isAutofill && !isFido2Creation + val shouldShowOverflowMenu: Boolean get() = !isAutofill && !isFido2Creation && !isTotp /** * Represents the current state of any dialogs on the screen. @@ -2081,6 +2118,11 @@ data class VaultItemListingState( * Models events for the [VaultItemListingScreen]. */ sealed class VaultItemListingEvent { + /** + * Closes the app. + */ + data object ExitApp : VaultItemListingEvent() + /** * Navigates to the Create Account screen. */ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a62c7129c..95bade84c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -605,6 +605,7 @@ Scanning will happen automatically. 30 days Custom Share this Send upon save + Add this authenticator key to an existing login, or create a new login. Due to an enterprise policy, you are only able to delete an existing Send. About Send Hide my email address from recipients diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt index d54c7e6ab..c2f8b9f67 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt @@ -62,6 +62,7 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.model.toCustomField import com.x8bit.bitwarden.ui.vault.feature.addedit.util.createMockPasskeyAttestationOptions import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toDefaultAddTypeContent import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toViewState +import com.x8bit.bitwarden.ui.vault.model.TotpData import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand import com.x8bit.bitwarden.ui.vault.model.VaultCardExpirationMonth @@ -699,6 +700,60 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") + @Test + fun `in add mode during totp fill, SaveClick should show dialog, remove it once an item is saved, and emit ExitApp`() = + runTest { + val totpData = TotpData( + uri = "otpauth://totp/issuer:accountName?secret=secret", + issuer = "issuer", + accountName = "accountName", + secret = "secret", + digits = 6, + period = 30, + algorithm = TotpData.CryptoHashAlgorithm.SHA_1, + ) + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.AddTotpLoginItem(data = totpData) + val stateWithDialog = createVaultAddItemState( + vaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.LOGIN), + dialogState = VaultAddEditState.DialogState.Loading(R.string.saving.asText()), + commonContentViewState = createCommonContentViewState(name = "issuer"), + totpData = totpData, + shouldExitOnSave = true, + ) + val stateWithName = createVaultAddItemState( + vaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.LOGIN), + commonContentViewState = createCommonContentViewState(name = "issuer"), + totpData = totpData, + shouldExitOnSave = true, + ) + mutableVaultDataFlow.value = DataState.Loaded(createVaultData()) + val viewModel = createAddVaultItemViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = stateWithName, + vaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.LOGIN), + ), + ) + coEvery { + vaultRepository.createCipherInOrganization(any(), any()) + } returns CreateCipherResult.Success + + viewModel.stateEventFlow(backgroundScope) { stateTurbine, eventTurbine -> + viewModel.trySendAction(VaultAddEditAction.Common.SaveClick) + + assertEquals(stateWithName, stateTurbine.awaitItem()) + assertEquals(stateWithDialog, stateTurbine.awaitItem()) + assertEquals(stateWithName, stateTurbine.awaitItem()) + + assertEquals(VaultAddEditEvent.ExitApp, eventTurbine.awaitItem()) + } + assertNull(specialCircumstanceManager.specialCircumstance) + coVerify(exactly = 1) { + vaultRepository.createCipherInOrganization(any(), any()) + } + } + @Suppress("MaxLineLength") @Test fun `in add mode during fido2 registration, SaveClick should show saving dialog, and request user verification when required`() = @@ -1204,6 +1259,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { cipherView.toViewState( isClone = false, isIndividualVaultDisabled = false, + totpData = null, resourceManager = resourceManager, clock = fixedClock, ) @@ -1234,6 +1290,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { cipherView.toViewState( isClone = false, isIndividualVaultDisabled = false, + totpData = null, resourceManager = resourceManager, clock = fixedClock, ) @@ -1267,6 +1324,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { cipherView.toViewState( isClone = false, isIndividualVaultDisabled = false, + totpData = null, resourceManager = resourceManager, clock = fixedClock, ) @@ -1328,6 +1386,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { cipherView.toViewState( isClone = false, isIndividualVaultDisabled = false, + totpData = null, resourceManager = resourceManager, clock = fixedClock, ) @@ -1392,6 +1451,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { cipherView.toViewState( isClone = false, isIndividualVaultDisabled = false, + totpData = null, resourceManager = resourceManager, clock = fixedClock, ) @@ -1447,6 +1507,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { cipherView.toViewState( isClone = false, isIndividualVaultDisabled = false, + totpData = null, resourceManager = resourceManager, clock = fixedClock, ) @@ -1507,6 +1568,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { cipherView.toViewState( isClone = false, isIndividualVaultDisabled = false, + totpData = null, resourceManager = resourceManager, clock = fixedClock, ) @@ -3737,13 +3799,15 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { //region Helper functions - @Suppress("MaxLineLength") + @Suppress("MaxLineLength", "LongParameterList") private fun createVaultAddItemState( vaultAddEditType: VaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.LOGIN), commonContentViewState: VaultAddEditState.ViewState.Content.Common = createCommonContentViewState(), isIndividualVaultDisabled: Boolean = false, + shouldExitOnSave: Boolean = false, typeContentViewState: VaultAddEditState.ViewState.Content.ItemType = createLoginTypeContentViewState(), dialogState: VaultAddEditState.DialogState? = null, + totpData: TotpData? = null, ): VaultAddEditState = VaultAddEditState( vaultAddEditType = vaultAddEditType, @@ -3753,6 +3817,8 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { type = typeContentViewState, ), dialog = dialogState, + shouldExitOnSave = shouldExitOnSave, + totpData = totpData, ) @Suppress("LongParameterList") diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt index 2386f8b52..bb533d1f3 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt @@ -70,6 +70,7 @@ class CipherViewExtensionsTest { val result = cipherView.toViewState( isClone = false, isIndividualVaultDisabled = false, + totpData = null, resourceManager = resourceManager, clock = FIXED_CLOCK, ) @@ -115,6 +116,7 @@ class CipherViewExtensionsTest { val result = cipherView.toViewState( isClone = false, isIndividualVaultDisabled = true, + totpData = null, resourceManager = resourceManager, clock = FIXED_CLOCK, ) @@ -165,6 +167,7 @@ class CipherViewExtensionsTest { val result = cipherView.toViewState( isClone = false, isIndividualVaultDisabled = false, + totpData = null, resourceManager = resourceManager, clock = FIXED_CLOCK, ) @@ -214,6 +217,66 @@ class CipherViewExtensionsTest { ) } + @Test + fun `toViewState should create a Login ViewState with a predefined totp`() { + val totp = "otpauth://totp/alice@google.com?secret=JBSWY3DPEHPK3PXP" + val cipherView = DEFAULT_LOGIN_CIPHER_VIEW.copy( + login = DEFAULT_LOGIN_CIPHER_VIEW.login?.copy(totp = null), + ) + + val result = cipherView.toViewState( + isClone = false, + isIndividualVaultDisabled = false, + totpData = mockk { every { uri } returns totp }, + resourceManager = resourceManager, + clock = FIXED_CLOCK, + ) + + assertEquals( + VaultAddEditState.ViewState.Content( + common = VaultAddEditState.ViewState.Content.Common( + originalCipher = cipherView, + name = "cipher", + favorite = false, + masterPasswordReprompt = true, + notes = "Lots of notes", + availableFolders = emptyList(), + availableOwners = emptyList(), + customFieldData = listOf( + VaultAddEditState.Custom.BooleanField(TEST_ID, "TestBoolean", false), + VaultAddEditState.Custom.TextField(TEST_ID, "TestText", "TestText"), + VaultAddEditState.Custom.HiddenField(TEST_ID, "TestHidden", "TestHidden"), + VaultAddEditState.Custom.LinkedField( + TEST_ID, + "TestLinked", + VaultLinkedFieldType.USERNAME, + ), + ), + ), + isIndividualVaultDisabled = false, + type = VaultAddEditState.ViewState.Content.ItemType.Login( + username = "username", + password = "password", + uriList = listOf( + UriItem( + id = TEST_ID, + uri = "www.example.com", + match = null, + checksum = null, + ), + ), + totp = totp, + canViewPassword = false, + fido2CredentialCreationDateTime = R.string.created_xy.asText( + "10/27/23", + "12:00 PM", + ), + ), + ), + result, + ) + } + @Test fun `toViewState should create a Secure Notes ViewState`() { val cipherView = DEFAULT_SECURE_NOTES_CIPHER_VIEW @@ -221,6 +284,7 @@ class CipherViewExtensionsTest { val result = cipherView.toViewState( isClone = false, isIndividualVaultDisabled = true, + totpData = null, resourceManager = resourceManager, clock = FIXED_CLOCK, ) @@ -255,6 +319,7 @@ class CipherViewExtensionsTest { val result = cipherView.toViewState( isClone = true, isIndividualVaultDisabled = false, + totpData = null, resourceManager = resourceManager, clock = FIXED_CLOCK, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/TotpDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/TotpDataExtensionsTest.kt new file mode 100644 index 000000000..e17177fb3 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/TotpDataExtensionsTest.kt @@ -0,0 +1,48 @@ +package com.x8bit.bitwarden.ui.vault.feature.addedit.util + +import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditState +import com.x8bit.bitwarden.ui.vault.model.TotpData +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.UUID + +class TotpDataExtensionsTest { + + @BeforeEach + fun setup() { + mockkStatic(UUID::class) + every { UUID.randomUUID().toString() } returns "uuid" + } + + @AfterEach + fun tearDown() { + unmockkStatic(UUID::class) + } + + @Test + fun `toDefaultAddTypeContent should return the correct content with totp data`() { + val uri = "otpauth://totp/issuer:accountName?secret=secret" + val totpData = TotpData( + uri = uri, + issuer = "issuer", + accountName = "accountName", + secret = "secret", + digits = 6, + period = 30, + algorithm = TotpData.CryptoHashAlgorithm.SHA_1, + ) + assertEquals( + VaultAddEditState.ViewState.Content( + common = VaultAddEditState.ViewState.Content.Common(name = "issuer"), + isIndividualVaultDisabled = false, + type = VaultAddEditState.ViewState.Content.ItemType.Login(totp = uri), + ), + totpData.toDefaultAddTypeContent(isIndividualVaultDisabled = false), + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt index baeb2c7c6..15847b22e 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt @@ -35,6 +35,7 @@ import com.x8bit.bitwarden.ui.platform.components.model.IconData import com.x8bit.bitwarden.ui.platform.components.model.IconRes import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager +import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.util.assertLockOrLogoutDialogIsDisplayed import com.x8bit.bitwarden.ui.util.assertLogoutConfirmationDialogIsDisplayed @@ -84,6 +85,9 @@ class VaultItemListingScreenTest : BaseComposeTest() { private var onNavigateToSearchType: SearchType? = null private var onNavigateToVaultItemListingScreenType: VaultItemListingType? = null + private val exitManager: ExitManager = mockk { + every { exitApplication() } just runs + } private val intentManager: IntentManager = mockk { every { shareText(any()) } just runs every { launchUri(any()) } just runs @@ -105,9 +109,10 @@ class VaultItemListingScreenTest : BaseComposeTest() { fun setUp() { mockkStatic(String::toHostOrPathOrNull) every { AUTOFILL_SELECTION_DATA.uri?.toHostOrPathOrNull() } returns "www.test.com" - composeTestRule.setContent { + setContentWithBackDispatcher { VaultItemListingScreen( viewModel = viewModel, + exitManager = exitManager, intentManager = intentManager, fido2CompletionManager = fido2CompletionManager, biometricsManager = biometricsManager, @@ -339,6 +344,23 @@ class VaultItemListingScreenTest : BaseComposeTest() { assertTrue(onNavigateBackCalled) } + @Test + fun `ExitApp event should invoke exitApplication`() { + mutableEventFlow.tryEmit(VaultItemListingEvent.ExitApp) + verify(exactly = 1) { + exitManager.exitApplication() + } + } + + @Test + fun `back gesture should send BackClick action`() { + backDispatcher?.onBackPressed() + + verify(exactly = 1) { + viewModel.trySendAction(VaultItemListingsAction.BackClick) + } + } + @Test fun `clicking back button should send BackClick action`() { composeTestRule diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index 864e57f33..2bdb9611d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -70,6 +70,7 @@ import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.createMockDisplayIt import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries import com.x8bit.bitwarden.ui.vault.feature.vault.util.toActiveAccountSummary +import com.x8bit.bitwarden.ui.vault.model.TotpData import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType import io.mockk.Ordering @@ -259,6 +260,26 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { verify { authRepository.switchAccount(userId = updatedUserId) } } + @Test + fun `BackClick with TotpData should emit ExitApp`() = runTest { + val totpData = TotpData( + uri = "otpauth://totp/issuer:accountName?secret=secret", + issuer = "issuer", + accountName = "accountName", + secret = "secret", + digits = 6, + period = 30, + algorithm = TotpData.CryptoHashAlgorithm.SHA_1, + ) + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.AddTotpLoginItem(data = totpData) + val viewModel = createVaultItemListingViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(VaultItemListingsAction.BackClick) + assertEquals(VaultItemListingEvent.ExitApp, awaitItem()) + } + } + @Test fun `BackClick should emit NavigateBack`() = runTest { val viewModel = createVaultItemListingViewModel() @@ -1400,6 +1421,61 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") + @Test + fun `vaultDataStateFlow Loaded with items and totp data filtering should update ViewState to Content with filtered data`() = + runTest { + setupMockUri() + + val uri = "otpauth://totp/issuer:accountName?secret=secret" + val totpData = TotpData( + uri = uri, + issuer = null, + accountName = "Name-1", + secret = "secret", + digits = 6, + period = 30, + algorithm = TotpData.CryptoHashAlgorithm.SHA_1, + ) + val cipherView1 = createMockCipherView(number = 1) + val cipherView2 = createMockCipherView(number = 2) + + // Filtering comes later, so we return everything here + mockFilteredCiphers = listOf(cipherView1, cipherView2) + + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.AddTotpLoginItem(data = totpData) + val dataState = DataState.Loaded( + data = VaultData( + cipherViewList = listOf(cipherView1, cipherView2), + folderViewList = listOf(createMockFolderView(number = 1)), + collectionViewList = listOf(createMockCollectionView(number = 1)), + sendViewList = listOf(createMockSendView(number = 1)), + ), + ) + + val viewModel = createVaultItemListingViewModel() + + mutableVaultDataStateFlow.value = dataState + + assertEquals( + createVaultItemListingState( + viewState = VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), + displayItemList = listOf( + createMockDisplayItemForCipher( + number = 1, + secondSubtitleTestTag = "PasskeySite", + ), + ), + displayFolderList = emptyList(), + ), + ) + .copy(totpData = totpData), + viewModel.stateFlow.value, + ) + } + @Suppress("MaxLineLength") @Test fun `vaultDataStateFlow Loaded with items and fido2 filtering should update ViewState to Content with filtered data`() = @@ -3887,7 +3963,6 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { private fun createVaultItemListingViewModel( savedStateHandle: SavedStateHandle = initialSavedStateHandle, - vaultRepository: VaultRepository = this.vaultRepository, ): VaultItemListingViewModel = VaultItemListingViewModel( savedStateHandle = savedStateHandle, @@ -3922,6 +3997,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled, isPullToRefreshSettingEnabled = false, dialogState = null, + totpData = null, autofillSelectionData = null, policyDisablesSend = false, hasMasterPassword = true, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt index 42ca010e0..7419b8070 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt @@ -16,6 +16,7 @@ fun createMockDisplayItemForCipher( number: Int, cipherType: CipherType = CipherType.LOGIN, subtitle: String? = "mockUsername-$number", + secondSubtitleTestTag: String? = null, requiresPasswordReprompt: Boolean = true, ): VaultItemListingState.DisplayItem = when (cipherType) { @@ -25,11 +26,11 @@ fun createMockDisplayItemForCipher( title = "mockName-$number", titleTestTag = "CipherNameLabel", secondSubtitle = null, - secondSubtitleTestTag = null, + secondSubtitleTestTag = secondSubtitleTestTag, subtitle = subtitle, subtitleTestTag = "CipherSubTitleLabel", iconData = IconData.Network( - "https://vault.bitwarden.com/icons/www.mockuri.com/icon.png", + uri = "https://vault.bitwarden.com/icons/www.mockuri.com/icon.png", fallbackIconRes = R.drawable.ic_globe, ), extraIconList = listOf( @@ -79,7 +80,7 @@ fun createMockDisplayItemForCipher( title = "mockName-$number", titleTestTag = "CipherNameLabel", secondSubtitle = null, - secondSubtitleTestTag = null, + secondSubtitleTestTag = secondSubtitleTestTag, subtitle = subtitle, subtitleTestTag = "CipherSubTitleLabel", iconData = IconData.Local(R.drawable.ic_note), @@ -119,7 +120,7 @@ fun createMockDisplayItemForCipher( title = "mockName-$number", titleTestTag = "CipherNameLabel", secondSubtitle = null, - secondSubtitleTestTag = null, + secondSubtitleTestTag = secondSubtitleTestTag, subtitle = subtitle, subtitleTestTag = "CipherSubTitleLabel", iconData = IconData.Local(R.drawable.ic_payment_card), @@ -165,7 +166,7 @@ fun createMockDisplayItemForCipher( title = "mockName-$number", titleTestTag = "CipherNameLabel", secondSubtitle = null, - secondSubtitleTestTag = null, + secondSubtitleTestTag = secondSubtitleTestTag, subtitle = subtitle, subtitleTestTag = "CipherSubTitleLabel", iconData = IconData.Local(R.drawable.ic_id_card),