This PR adds the TOTP matching flow to the app (#4042)

This commit is contained in:
David Perez 2024-10-08 10:10:18 -05:00 committed by GitHub
parent 641a48fe44
commit e7450171cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 449 additions and 67 deletions

View file

@ -89,6 +89,15 @@
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="otpauth" />
<data android:host="totp" />
</intent-filter>
</activity> </activity>
<activity <activity

View file

@ -24,6 +24,7 @@ import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
import com.x8bit.bitwarden.data.platform.manager.util.toAutofillSaveItemOrNull import com.x8bit.bitwarden.data.platform.manager.util.toAutofillSaveItemOrNull
import com.x8bit.bitwarden.data.platform.manager.util.toAutofillSelectionDataOrNull import com.x8bit.bitwarden.data.platform.manager.util.toAutofillSelectionDataOrNull
import com.x8bit.bitwarden.data.platform.manager.util.toFido2RequestOrNull 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.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.takeUntilLoaded import com.x8bit.bitwarden.data.platform.repository.util.takeUntilLoaded
@ -53,6 +54,7 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toItemType
import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toViewState import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toViewState
import com.x8bit.bitwarden.ui.vault.feature.addedit.util.validateCipherOrReturnErrorState import com.x8bit.bitwarden.ui.vault.feature.addedit.util.validateCipherOrReturnErrorState
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toCipherView import com.x8bit.bitwarden.ui.vault.feature.vault.util.toCipherView
import com.x8bit.bitwarden.ui.vault.model.TotpData
import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
import com.x8bit.bitwarden.ui.vault.model.VaultCardExpirationMonth import com.x8bit.bitwarden.ui.vault.model.VaultCardExpirationMonth
@ -108,33 +110,31 @@ class VaultAddEditViewModel @Inject constructor(
.getActivePolicies(type = PolicyTypeJson.PERSONAL_OWNERSHIP) .getActivePolicies(type = PolicyTypeJson.PERSONAL_OWNERSHIP)
.any() .any()
val specialCircumstance = specialCircumstanceManager.specialCircumstance
// Check for autofill data to pre-populate // Check for autofill data to pre-populate
val autofillSaveItem = specialCircumstanceManager val autofillSaveItem = specialCircumstance?.toAutofillSaveItemOrNull()
.specialCircumstance val autofillSelectionData = specialCircumstance?.toAutofillSelectionDataOrNull()
?.toAutofillSaveItemOrNull() // Check for totp data to pre-populate
val autofillSelectionData = specialCircumstanceManager val totpData = specialCircumstance?.toTotpDataOrNull()
.specialCircumstance // Check for Fido2 data to pre-populate
?.toAutofillSelectionDataOrNull() val fido2CreationRequest = specialCircumstance?.toFido2RequestOrNull()
val fido2AttestationOptions = fido2CreationRequest?.let { request ->
fido2CredentialManager.getPasskeyAttestationOptionsOrNull(request.requestJson)
}
val fido2CreationRequest = specialCircumstanceManager // Exit on save if handling an autofill, Fido2 Attestation, or TOTP link
.specialCircumstance val shouldExitOnSave = autofillSaveItem != null ||
?.toFido2RequestOrNull() fido2AttestationOptions != null ||
totpData != null
val fido2AttestationOptions = fido2CreationRequest val dialogState = if (!settingsRepository.initialAutofillDialogShown &&
?.let { request -> vaultAddEditType is VaultAddEditType.AddItem &&
fido2CredentialManager autofillSelectionData == null
.getPasskeyAttestationOptionsOrNull(request.requestJson) ) {
} VaultAddEditState.DialogState.InitialAutofillPrompt
} else {
val dialogState = null
if (!settingsRepository.initialAutofillDialogShown && }
vaultAddEditType is VaultAddEditType.AddItem &&
autofillSelectionData == null
) {
VaultAddEditState.DialogState.InitialAutofillPrompt
} else {
null
}
VaultAddEditState( VaultAddEditState(
vaultAddEditType = vaultAddEditType, vaultAddEditType = vaultAddEditType,
@ -142,13 +142,12 @@ class VaultAddEditViewModel @Inject constructor(
is VaultAddEditType.AddItem -> { is VaultAddEditType.AddItem -> {
autofillSelectionData autofillSelectionData
?.toDefaultAddTypeContent(isIndividualVaultDisabled) ?.toDefaultAddTypeContent(isIndividualVaultDisabled)
?: autofillSaveItem ?: autofillSaveItem?.toDefaultAddTypeContent(isIndividualVaultDisabled)
?.toDefaultAddTypeContent(isIndividualVaultDisabled) ?: fido2CreationRequest?.toDefaultAddTypeContent(
?: fido2CreationRequest attestationOptions = fido2AttestationOptions,
?.toDefaultAddTypeContent( isIndividualVaultDisabled = isIndividualVaultDisabled,
attestationOptions = fido2AttestationOptions, )
isIndividualVaultDisabled = isIndividualVaultDisabled, ?: totpData?.toDefaultAddTypeContent(isIndividualVaultDisabled)
)
?: VaultAddEditState.ViewState.Content( ?: VaultAddEditState.ViewState.Content(
common = VaultAddEditState.ViewState.Content.Common(), common = VaultAddEditState.ViewState.Content.Common(),
isIndividualVaultDisabled = isIndividualVaultDisabled, isIndividualVaultDisabled = isIndividualVaultDisabled,
@ -160,9 +159,10 @@ class VaultAddEditViewModel @Inject constructor(
is VaultAddEditType.CloneItem -> VaultAddEditState.ViewState.Loading is VaultAddEditType.CloneItem -> VaultAddEditState.ViewState.Loading
}, },
dialog = dialogState, dialog = dialogState,
totpData = totpData,
// Set special conditions for autofill and fido2 save // Set special conditions for autofill and fido2 save
shouldShowCloseButton = autofillSaveItem == null && fido2AttestationOptions == null, shouldShowCloseButton = autofillSaveItem == null && fido2AttestationOptions == null,
shouldExitOnSave = autofillSaveItem != null || fido2AttestationOptions != null, shouldExitOnSave = shouldExitOnSave,
) )
}, },
) { ) {
@ -1412,18 +1412,14 @@ class VaultAddEditViewModel @Inject constructor(
} }
is CreateCipherResult.Success -> { is CreateCipherResult.Success -> {
specialCircumstanceManager.specialCircumstance = null
if (state.shouldExitOnSave) { if (state.shouldExitOnSave) {
specialCircumstanceManager.specialCircumstance = null sendEvent(event = VaultAddEditEvent.ExitApp)
sendEvent(
event = VaultAddEditEvent.ExitApp,
)
} else { } else {
sendEvent( sendEvent(
event = VaultAddEditEvent.ShowToast(R.string.new_item_created.asText()), event = VaultAddEditEvent.ShowToast(R.string.new_item_created.asText()),
) )
sendEvent( sendEvent(event = VaultAddEditEvent.NavigateBack)
event = VaultAddEditEvent.NavigateBack,
)
} }
} }
} }
@ -1444,10 +1440,13 @@ class VaultAddEditViewModel @Inject constructor(
} }
is UpdateCipherResult.Success -> { is UpdateCipherResult.Success -> {
sendEvent( specialCircumstanceManager.specialCircumstance = null
event = VaultAddEditEvent.ShowToast(R.string.item_updated.asText()), if (state.shouldExitOnSave) {
) sendEvent(event = VaultAddEditEvent.ExitApp)
sendEvent(VaultAddEditEvent.NavigateBack) } else {
sendEvent(event = VaultAddEditEvent.ShowToast(R.string.item_updated.asText()))
sendEvent(event = VaultAddEditEvent.NavigateBack)
}
} }
} }
} }
@ -1544,15 +1543,19 @@ class VaultAddEditViewModel @Inject constructor(
) { currentAccount, cipherView -> ) { currentAccount, cipherView ->
// Derive the view state from the current Cipher for Edit mode // Derive the view state from the current Cipher for Edit mode
// or use the current state for Add // or use the current state for Add
(cipherView?.toViewState( (cipherView
isClone = isCloneMode, ?.toViewState(
isIndividualVaultDisabled = isIndividualVaultDisabled, isClone = isCloneMode,
resourceManager = resourceManager, isIndividualVaultDisabled = isIndividualVaultDisabled,
clock = clock, totpData = totpData,
) ?: viewState) resourceManager = resourceManager,
clock = clock,
)
?: viewState)
.appendFolderAndOwnerData( .appendFolderAndOwnerData(
folderViewList = vaultData.folderViewList, folderViewList = vaultData.folderViewList,
collectionViewList = vaultData.collectionViewList collectionViewList = vaultData
.collectionViewList
.filter { !it.readOnly }, .filter { !it.readOnly },
activeAccount = currentAccount, activeAccount = currentAccount,
isIndividualVaultDisabled = isIndividualVaultDisabled, isIndividualVaultDisabled = isIndividualVaultDisabled,
@ -1911,6 +1914,7 @@ data class VaultAddEditState(
val shouldShowCloseButton: Boolean = true, val shouldShowCloseButton: Boolean = true,
// Internal // Internal
val shouldExitOnSave: Boolean = false, val shouldExitOnSave: Boolean = false,
val totpData: TotpData? = null,
) : Parcelable { ) : Parcelable {
/** /**

View file

@ -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.platform.util.toFormattedPattern
import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditState 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.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.VaultAddEditType
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
import com.x8bit.bitwarden.ui.vault.model.VaultCardExpirationMonth 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( fun CipherView.toViewState(
isClone: Boolean, isClone: Boolean,
isIndividualVaultDisabled: Boolean, isIndividualVaultDisabled: Boolean,
totpData: TotpData?,
resourceManager: ResourceManager, resourceManager: ResourceManager,
clock: Clock, clock: Clock,
): VaultAddEditState.ViewState = ): VaultAddEditState.ViewState =
@ -46,7 +48,7 @@ fun CipherView.toViewState(
VaultAddEditState.ViewState.Content.ItemType.Login( VaultAddEditState.ViewState.Content.ItemType.Login(
username = login?.username.orEmpty(), username = login?.username.orEmpty(),
password = login?.password.orEmpty(), password = login?.password.orEmpty(),
totp = login?.totp, totp = totpData?.uri ?: login?.totp,
canViewPassword = this.viewPassword, canViewPassword = this.viewPassword,
canEditItem = this.edit, canEditItem = this.edit,
uriList = login?.uris.toUriItems(), uriList = login?.uris.toUriItems(),

View file

@ -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),
)

View file

@ -15,6 +15,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenMasterPasswordDialog 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.model.toIconResources
import com.x8bit.bitwarden.ui.platform.components.text.BitwardenPolicyWarningText import com.x8bit.bitwarden.ui.platform.components.text.BitwardenPolicyWarningText
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter 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 com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
@ -38,6 +40,7 @@ import kotlinx.collections.immutable.toPersistentList
fun VaultItemListingContent( fun VaultItemListingContent(
state: VaultItemListingState.ViewState.Content, state: VaultItemListingState.ViewState.Content,
policyDisablesSend: Boolean, policyDisablesSend: Boolean,
showAddTotpBanner: Boolean,
collectionClick: (id: String) -> Unit, collectionClick: (id: String) -> Unit,
folderClick: (id: String) -> Unit, folderClick: (id: String) -> Unit,
vaultItemClick: (id: String) -> Unit, vaultItemClick: (id: String) -> Unit,
@ -100,8 +103,23 @@ fun VaultItemListingContent(
LazyColumn( LazyColumn(
modifier = modifier, 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 { item {
if (policyDisablesSend) { if (policyDisablesSend) {
Spacer(modifier = Modifier.height(height = 12.dp))
BitwardenPolicyWarningText( BitwardenPolicyWarningText(
text = stringResource(id = R.string.send_disabled_warning), text = stringResource(id = R.string.send_disabled_warning),
modifier = Modifier modifier = Modifier

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.vault.feature.itemlisting package com.x8bit.bitwarden.ui.vault.feature.itemlisting
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api 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.scaffold.rememberBitwardenPullToRefreshState
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.composition.LocalBiometricsManager 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.LocalFido2CompletionManager
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager 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.search.model.SearchType
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.PinInputDialog 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.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.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.handlers.VaultItemListingHandlers import com.x8bit.bitwarden.ui.vault.feature.itemlisting.handlers.VaultItemListingHandlers
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.handlers.VaultItemListingUserVerificationHandlers import com.x8bit.bitwarden.ui.vault.feature.itemlisting.handlers.VaultItemListingUserVerificationHandlers
@ -74,6 +77,7 @@ fun VaultItemListingScreen(
onNavigateToEditSendItem: (sendId: String) -> Unit, onNavigateToEditSendItem: (sendId: String) -> Unit,
onNavigateToSearch: (searchType: SearchType) -> Unit, onNavigateToSearch: (searchType: SearchType) -> Unit,
intentManager: IntentManager = LocalIntentManager.current, intentManager: IntentManager = LocalIntentManager.current,
exitManager: ExitManager = LocalExitManager.current,
fido2CompletionManager: Fido2CompletionManager = LocalFido2CompletionManager.current, fido2CompletionManager: Fido2CompletionManager = LocalFido2CompletionManager.current,
biometricsManager: BiometricsManager = LocalBiometricsManager.current, biometricsManager: BiometricsManager = LocalBiometricsManager.current,
viewModel: VaultItemListingViewModel = hiltViewModel(), viewModel: VaultItemListingViewModel = hiltViewModel(),
@ -168,6 +172,8 @@ fun VaultItemListingScreen(
is VaultItemListingEvent.CompleteFido2GetCredentialsRequest -> { is VaultItemListingEvent.CompleteFido2GetCredentialsRequest -> {
fido2CompletionManager.completeFido2GetCredentialRequest(event.result) 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( VaultItemListingScaffold(
state = state, state = state,
pullToRefreshState = pullToRefreshState, pullToRefreshState = pullToRefreshState,
vaultItemListingHandlers = remember(viewModel) { vaultItemListingHandlers = vaultItemListingHandlers,
VaultItemListingHandlers.create(viewModel)
},
) )
} }
@ -451,6 +460,7 @@ private fun VaultItemListingScaffold(
is VaultItemListingState.ViewState.Content -> { is VaultItemListingState.ViewState.Content -> {
VaultItemListingContent( VaultItemListingContent(
state = state.viewState, state = state.viewState,
showAddTotpBanner = state.isTotp,
policyDisablesSend = state.policyDisablesSend && policyDisablesSend = state.policyDisablesSend &&
state.itemListingType is VaultItemListingState.ItemListingType.Send, state.itemListingType is VaultItemListingState.ItemListingType.Send,
vaultItemClick = vaultItemListingHandlers.itemClick, vaultItemClick = vaultItemListingHandlers.itemClick,

View file

@ -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.toFido2AssertionRequestOrNull
import com.x8bit.bitwarden.data.platform.manager.util.toFido2GetCredentialsRequestOrNull 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.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.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.DataState 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.AccountSummary
import com.x8bit.bitwarden.ui.platform.components.model.IconData import com.x8bit.bitwarden.ui.platform.components.model.IconData
import com.x8bit.bitwarden.ui.platform.components.model.IconRes 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.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.model.ListingItemOverflowAction
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.determineListingPredicate import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.determineListingPredicate
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.toItemListingType 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.toAccountSummaries
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toActiveAccountSummary 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.feature.vault.util.toFilteredList
import com.x8bit.bitwarden.ui.vault.model.TotpData
import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -127,6 +131,7 @@ class VaultItemListingViewModel @Inject constructor(
.any(), .any(),
autofillSelectionData = specialCircumstance?.toAutofillSelectionDataOrNull(), autofillSelectionData = specialCircumstance?.toAutofillSelectionDataOrNull(),
hasMasterPassword = userState.activeAccount.hasMasterPassword, hasMasterPassword = userState.activeAccount.hasMasterPassword,
totpData = specialCircumstance?.toTotpDataOrNull(),
fido2CredentialRequest = fido2CredentialRequest, fido2CredentialRequest = fido2CredentialRequest,
fido2CredentialAssertionRequest = specialCircumstance?.toFido2AssertionRequestOrNull(), fido2CredentialAssertionRequest = specialCircumstance?.toFido2AssertionRequestOrNull(),
fido2GetCredentialsRequest = specialCircumstance?.toFido2GetCredentialsRequestOrNull(), fido2GetCredentialsRequest = specialCircumstance?.toFido2GetCredentialsRequestOrNull(),
@ -184,7 +189,8 @@ class VaultItemListingViewModel @Inject constructor(
it it
.filterForAutofillIfNecessary() .filterForAutofillIfNecessary()
.filterForFido2CreationIfNecessary() .filterForFido2CreationIfNecessary()
.filterForFidoGetCredentialsIfNecessary(), .filterForFidoGetCredentialsIfNecessary()
.filterForTotpIfNecessary(),
) )
} }
.onEach(::sendAction) .onEach(::sendAction)
@ -560,6 +566,10 @@ class VaultItemListingViewModel @Inject constructor(
} }
return return
} }
state.totpData?.let {
sendEvent(VaultItemListingEvent.NavigateToEditCipher(cipherId = action.id))
return
}
if (state.isFido2Creation) { if (state.isFido2Creation) {
handleFido2RegistrationRequestReceive(action) handleFido2RegistrationRequestReceive(action)
@ -832,7 +842,11 @@ class VaultItemListingViewModel @Inject constructor(
private fun handleBackClick() { private fun handleBackClick() {
sendEvent( 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<VaultData>.filterForTotpIfNecessary(): DataState<VaultData> {
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. * Decrypt and filter the fido 2 autofill credentials.
*/ */
@ -1687,6 +1717,7 @@ data class VaultItemListingState(
val policyDisablesSend: Boolean, val policyDisablesSend: Boolean,
// Internal // Internal
private val isPullToRefreshSettingEnabled: Boolean, private val isPullToRefreshSettingEnabled: Boolean,
val totpData: TotpData? = null,
val autofillSelectionData: AutofillSelectionData? = null, val autofillSelectionData: AutofillSelectionData? = null,
val fido2CredentialRequest: Fido2CredentialRequest? = null, val fido2CredentialRequest: Fido2CredentialRequest? = null,
val fido2CredentialAssertionRequest: Fido2CredentialAssertionRequest? = null, val fido2CredentialAssertionRequest: Fido2CredentialAssertionRequest? = null,
@ -1707,6 +1738,11 @@ data class VaultItemListingState(
val isFido2Creation: Boolean val isFido2Creation: Boolean
get() = fido2CredentialRequest != null 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. * A displayable title for the AppBar.
*/ */
@ -1719,6 +1755,7 @@ data class VaultItemListingState(
?.callingAppInfo ?.callingAppInfo
?.getFido2RpIdOrNull() ?.getFido2RpIdOrNull()
?.let { R.string.items_for_uri.asText(it) } ?.let { R.string.items_for_uri.asText(it) }
?: totpData?.let { R.string.items_for_uri.asText(it.issuer ?: it.accountName ?: "--") }
?: itemListingType.titleText ?: itemListingType.titleText
/** /**
@ -1730,17 +1767,17 @@ data class VaultItemListingState(
/** /**
* Whether or not the account switcher should be shown. * 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. * 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. * 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. * Represents the current state of any dialogs on the screen.
@ -2081,6 +2118,11 @@ data class VaultItemListingState(
* Models events for the [VaultItemListingScreen]. * Models events for the [VaultItemListingScreen].
*/ */
sealed class VaultItemListingEvent { sealed class VaultItemListingEvent {
/**
* Closes the app.
*/
data object ExitApp : VaultItemListingEvent()
/** /**
* Navigates to the Create Account screen. * Navigates to the Create Account screen.
*/ */

View file

@ -605,6 +605,7 @@ Scanning will happen automatically.</string>
<string name="thirty_days">30 days</string> <string name="thirty_days">30 days</string>
<string name="custom">Custom</string> <string name="custom">Custom</string>
<string name="share_on_save">Share this Send upon save</string> <string name="share_on_save">Share this Send upon save</string>
<string name="add_this_authenticator_key_to_a_login">Add this authenticator key to an existing login, or create a new login.</string>
<string name="send_disabled_warning">Due to an enterprise policy, you are only able to delete an existing Send.</string> <string name="send_disabled_warning">Due to an enterprise policy, you are only able to delete an existing Send.</string>
<string name="about_send">About Send</string> <string name="about_send">About Send</string>
<string name="hide_email">Hide my email address from recipients</string> <string name="hide_email">Hide my email address from recipients</string>

View file

@ -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.createMockPasskeyAttestationOptions
import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toDefaultAddTypeContent 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.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.VaultAddEditType
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
import com.x8bit.bitwarden.ui.vault.model.VaultCardExpirationMonth 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") @Suppress("MaxLineLength")
@Test @Test
fun `in add mode during fido2 registration, SaveClick should show saving dialog, and request user verification when required`() = 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( cipherView.toViewState(
isClone = false, isClone = false,
isIndividualVaultDisabled = false, isIndividualVaultDisabled = false,
totpData = null,
resourceManager = resourceManager, resourceManager = resourceManager,
clock = fixedClock, clock = fixedClock,
) )
@ -1234,6 +1290,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
cipherView.toViewState( cipherView.toViewState(
isClone = false, isClone = false,
isIndividualVaultDisabled = false, isIndividualVaultDisabled = false,
totpData = null,
resourceManager = resourceManager, resourceManager = resourceManager,
clock = fixedClock, clock = fixedClock,
) )
@ -1267,6 +1324,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
cipherView.toViewState( cipherView.toViewState(
isClone = false, isClone = false,
isIndividualVaultDisabled = false, isIndividualVaultDisabled = false,
totpData = null,
resourceManager = resourceManager, resourceManager = resourceManager,
clock = fixedClock, clock = fixedClock,
) )
@ -1328,6 +1386,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
cipherView.toViewState( cipherView.toViewState(
isClone = false, isClone = false,
isIndividualVaultDisabled = false, isIndividualVaultDisabled = false,
totpData = null,
resourceManager = resourceManager, resourceManager = resourceManager,
clock = fixedClock, clock = fixedClock,
) )
@ -1392,6 +1451,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
cipherView.toViewState( cipherView.toViewState(
isClone = false, isClone = false,
isIndividualVaultDisabled = false, isIndividualVaultDisabled = false,
totpData = null,
resourceManager = resourceManager, resourceManager = resourceManager,
clock = fixedClock, clock = fixedClock,
) )
@ -1447,6 +1507,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
cipherView.toViewState( cipherView.toViewState(
isClone = false, isClone = false,
isIndividualVaultDisabled = false, isIndividualVaultDisabled = false,
totpData = null,
resourceManager = resourceManager, resourceManager = resourceManager,
clock = fixedClock, clock = fixedClock,
) )
@ -1507,6 +1568,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
cipherView.toViewState( cipherView.toViewState(
isClone = false, isClone = false,
isIndividualVaultDisabled = false, isIndividualVaultDisabled = false,
totpData = null,
resourceManager = resourceManager, resourceManager = resourceManager,
clock = fixedClock, clock = fixedClock,
) )
@ -3737,13 +3799,15 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
//region Helper functions //region Helper functions
@Suppress("MaxLineLength") @Suppress("MaxLineLength", "LongParameterList")
private fun createVaultAddItemState( private fun createVaultAddItemState(
vaultAddEditType: VaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.LOGIN), vaultAddEditType: VaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.LOGIN),
commonContentViewState: VaultAddEditState.ViewState.Content.Common = createCommonContentViewState(), commonContentViewState: VaultAddEditState.ViewState.Content.Common = createCommonContentViewState(),
isIndividualVaultDisabled: Boolean = false, isIndividualVaultDisabled: Boolean = false,
shouldExitOnSave: Boolean = false,
typeContentViewState: VaultAddEditState.ViewState.Content.ItemType = createLoginTypeContentViewState(), typeContentViewState: VaultAddEditState.ViewState.Content.ItemType = createLoginTypeContentViewState(),
dialogState: VaultAddEditState.DialogState? = null, dialogState: VaultAddEditState.DialogState? = null,
totpData: TotpData? = null,
): VaultAddEditState = ): VaultAddEditState =
VaultAddEditState( VaultAddEditState(
vaultAddEditType = vaultAddEditType, vaultAddEditType = vaultAddEditType,
@ -3753,6 +3817,8 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
type = typeContentViewState, type = typeContentViewState,
), ),
dialog = dialogState, dialog = dialogState,
shouldExitOnSave = shouldExitOnSave,
totpData = totpData,
) )
@Suppress("LongParameterList") @Suppress("LongParameterList")

View file

@ -70,6 +70,7 @@ class CipherViewExtensionsTest {
val result = cipherView.toViewState( val result = cipherView.toViewState(
isClone = false, isClone = false,
isIndividualVaultDisabled = false, isIndividualVaultDisabled = false,
totpData = null,
resourceManager = resourceManager, resourceManager = resourceManager,
clock = FIXED_CLOCK, clock = FIXED_CLOCK,
) )
@ -115,6 +116,7 @@ class CipherViewExtensionsTest {
val result = cipherView.toViewState( val result = cipherView.toViewState(
isClone = false, isClone = false,
isIndividualVaultDisabled = true, isIndividualVaultDisabled = true,
totpData = null,
resourceManager = resourceManager, resourceManager = resourceManager,
clock = FIXED_CLOCK, clock = FIXED_CLOCK,
) )
@ -165,6 +167,7 @@ class CipherViewExtensionsTest {
val result = cipherView.toViewState( val result = cipherView.toViewState(
isClone = false, isClone = false,
isIndividualVaultDisabled = false, isIndividualVaultDisabled = false,
totpData = null,
resourceManager = resourceManager, resourceManager = resourceManager,
clock = FIXED_CLOCK, 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 @Test
fun `toViewState should create a Secure Notes ViewState`() { fun `toViewState should create a Secure Notes ViewState`() {
val cipherView = DEFAULT_SECURE_NOTES_CIPHER_VIEW val cipherView = DEFAULT_SECURE_NOTES_CIPHER_VIEW
@ -221,6 +284,7 @@ class CipherViewExtensionsTest {
val result = cipherView.toViewState( val result = cipherView.toViewState(
isClone = false, isClone = false,
isIndividualVaultDisabled = true, isIndividualVaultDisabled = true,
totpData = null,
resourceManager = resourceManager, resourceManager = resourceManager,
clock = FIXED_CLOCK, clock = FIXED_CLOCK,
) )
@ -255,6 +319,7 @@ class CipherViewExtensionsTest {
val result = cipherView.toViewState( val result = cipherView.toViewState(
isClone = true, isClone = true,
isIndividualVaultDisabled = false, isIndividualVaultDisabled = false,
totpData = null,
resourceManager = resourceManager, resourceManager = resourceManager,
clock = FIXED_CLOCK, clock = FIXED_CLOCK,
) )

View file

@ -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),
)
}
}

View file

@ -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.components.model.IconRes
import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType 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.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.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.util.assertLockOrLogoutDialogIsDisplayed import com.x8bit.bitwarden.ui.util.assertLockOrLogoutDialogIsDisplayed
import com.x8bit.bitwarden.ui.util.assertLogoutConfirmationDialogIsDisplayed import com.x8bit.bitwarden.ui.util.assertLogoutConfirmationDialogIsDisplayed
@ -84,6 +85,9 @@ class VaultItemListingScreenTest : BaseComposeTest() {
private var onNavigateToSearchType: SearchType? = null private var onNavigateToSearchType: SearchType? = null
private var onNavigateToVaultItemListingScreenType: VaultItemListingType? = null private var onNavigateToVaultItemListingScreenType: VaultItemListingType? = null
private val exitManager: ExitManager = mockk {
every { exitApplication() } just runs
}
private val intentManager: IntentManager = mockk { private val intentManager: IntentManager = mockk {
every { shareText(any()) } just runs every { shareText(any()) } just runs
every { launchUri(any()) } just runs every { launchUri(any()) } just runs
@ -105,9 +109,10 @@ class VaultItemListingScreenTest : BaseComposeTest() {
fun setUp() { fun setUp() {
mockkStatic(String::toHostOrPathOrNull) mockkStatic(String::toHostOrPathOrNull)
every { AUTOFILL_SELECTION_DATA.uri?.toHostOrPathOrNull() } returns "www.test.com" every { AUTOFILL_SELECTION_DATA.uri?.toHostOrPathOrNull() } returns "www.test.com"
composeTestRule.setContent { setContentWithBackDispatcher {
VaultItemListingScreen( VaultItemListingScreen(
viewModel = viewModel, viewModel = viewModel,
exitManager = exitManager,
intentManager = intentManager, intentManager = intentManager,
fido2CompletionManager = fido2CompletionManager, fido2CompletionManager = fido2CompletionManager,
biometricsManager = biometricsManager, biometricsManager = biometricsManager,
@ -339,6 +344,23 @@ class VaultItemListingScreenTest : BaseComposeTest() {
assertTrue(onNavigateBackCalled) 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 @Test
fun `clicking back button should send BackClick action`() { fun `clicking back button should send BackClick action`() {
composeTestRule composeTestRule

View file

@ -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.model.VaultFilterType
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries 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.toActiveAccountSummary
import com.x8bit.bitwarden.ui.vault.model.TotpData
import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
import io.mockk.Ordering import io.mockk.Ordering
@ -259,6 +260,26 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
verify { authRepository.switchAccount(userId = updatedUserId) } 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 @Test
fun `BackClick should emit NavigateBack`() = runTest { fun `BackClick should emit NavigateBack`() = runTest {
val viewModel = createVaultItemListingViewModel() 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") @Suppress("MaxLineLength")
@Test @Test
fun `vaultDataStateFlow Loaded with items and fido2 filtering should update ViewState to Content with filtered data`() = 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( private fun createVaultItemListingViewModel(
savedStateHandle: SavedStateHandle = initialSavedStateHandle, savedStateHandle: SavedStateHandle = initialSavedStateHandle,
vaultRepository: VaultRepository = this.vaultRepository,
): VaultItemListingViewModel = ): VaultItemListingViewModel =
VaultItemListingViewModel( VaultItemListingViewModel(
savedStateHandle = savedStateHandle, savedStateHandle = savedStateHandle,
@ -3922,6 +3997,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled, isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled,
isPullToRefreshSettingEnabled = false, isPullToRefreshSettingEnabled = false,
dialogState = null, dialogState = null,
totpData = null,
autofillSelectionData = null, autofillSelectionData = null,
policyDisablesSend = false, policyDisablesSend = false,
hasMasterPassword = true, hasMasterPassword = true,

View file

@ -16,6 +16,7 @@ fun createMockDisplayItemForCipher(
number: Int, number: Int,
cipherType: CipherType = CipherType.LOGIN, cipherType: CipherType = CipherType.LOGIN,
subtitle: String? = "mockUsername-$number", subtitle: String? = "mockUsername-$number",
secondSubtitleTestTag: String? = null,
requiresPasswordReprompt: Boolean = true, requiresPasswordReprompt: Boolean = true,
): VaultItemListingState.DisplayItem = ): VaultItemListingState.DisplayItem =
when (cipherType) { when (cipherType) {
@ -25,11 +26,11 @@ fun createMockDisplayItemForCipher(
title = "mockName-$number", title = "mockName-$number",
titleTestTag = "CipherNameLabel", titleTestTag = "CipherNameLabel",
secondSubtitle = null, secondSubtitle = null,
secondSubtitleTestTag = null, secondSubtitleTestTag = secondSubtitleTestTag,
subtitle = subtitle, subtitle = subtitle,
subtitleTestTag = "CipherSubTitleLabel", subtitleTestTag = "CipherSubTitleLabel",
iconData = IconData.Network( 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, fallbackIconRes = R.drawable.ic_globe,
), ),
extraIconList = listOf( extraIconList = listOf(
@ -79,7 +80,7 @@ fun createMockDisplayItemForCipher(
title = "mockName-$number", title = "mockName-$number",
titleTestTag = "CipherNameLabel", titleTestTag = "CipherNameLabel",
secondSubtitle = null, secondSubtitle = null,
secondSubtitleTestTag = null, secondSubtitleTestTag = secondSubtitleTestTag,
subtitle = subtitle, subtitle = subtitle,
subtitleTestTag = "CipherSubTitleLabel", subtitleTestTag = "CipherSubTitleLabel",
iconData = IconData.Local(R.drawable.ic_note), iconData = IconData.Local(R.drawable.ic_note),
@ -119,7 +120,7 @@ fun createMockDisplayItemForCipher(
title = "mockName-$number", title = "mockName-$number",
titleTestTag = "CipherNameLabel", titleTestTag = "CipherNameLabel",
secondSubtitle = null, secondSubtitle = null,
secondSubtitleTestTag = null, secondSubtitleTestTag = secondSubtitleTestTag,
subtitle = subtitle, subtitle = subtitle,
subtitleTestTag = "CipherSubTitleLabel", subtitleTestTag = "CipherSubTitleLabel",
iconData = IconData.Local(R.drawable.ic_payment_card), iconData = IconData.Local(R.drawable.ic_payment_card),
@ -165,7 +166,7 @@ fun createMockDisplayItemForCipher(
title = "mockName-$number", title = "mockName-$number",
titleTestTag = "CipherNameLabel", titleTestTag = "CipherNameLabel",
secondSubtitle = null, secondSubtitle = null,
secondSubtitleTestTag = null, secondSubtitleTestTag = secondSubtitleTestTag,
subtitle = subtitle, subtitle = subtitle,
subtitleTestTag = "CipherSubTitleLabel", subtitleTestTag = "CipherSubTitleLabel",
iconData = IconData.Local(R.drawable.ic_id_card), iconData = IconData.Local(R.drawable.ic_id_card),