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" />
</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

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.toAutofillSelectionDataOrNull
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.model.DataState
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.validateCipherOrReturnErrorState
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.VaultCardBrand
import com.x8bit.bitwarden.ui.vault.model.VaultCardExpirationMonth
@ -108,26 +110,24 @@ class VaultAddEditViewModel @Inject constructor(
.getActivePolicies(type = PolicyTypeJson.PERSONAL_OWNERSHIP)
.any()
val specialCircumstance = specialCircumstanceManager.specialCircumstance
// Check for autofill data to pre-populate
val autofillSaveItem = specialCircumstanceManager
.specialCircumstance
?.toAutofillSaveItemOrNull()
val autofillSelectionData = specialCircumstanceManager
.specialCircumstance
?.toAutofillSelectionDataOrNull()
val fido2CreationRequest = specialCircumstanceManager
.specialCircumstance
?.toFido2RequestOrNull()
val fido2AttestationOptions = fido2CreationRequest
?.let { request ->
fido2CredentialManager
.getPasskeyAttestationOptionsOrNull(request.requestJson)
val autofillSaveItem = specialCircumstance?.toAutofillSaveItemOrNull()
val autofillSelectionData = specialCircumstance?.toAutofillSelectionDataOrNull()
// Check for totp data to pre-populate
val totpData = specialCircumstance?.toTotpDataOrNull()
// Check for Fido2 data to pre-populate
val fido2CreationRequest = specialCircumstance?.toFido2RequestOrNull()
val fido2AttestationOptions = fido2CreationRequest?.let { request ->
fido2CredentialManager.getPasskeyAttestationOptionsOrNull(request.requestJson)
}
val dialogState =
if (!settingsRepository.initialAutofillDialogShown &&
// Exit on save if handling an autofill, Fido2 Attestation, or TOTP link
val shouldExitOnSave = autofillSaveItem != null ||
fido2AttestationOptions != null ||
totpData != null
val dialogState = if (!settingsRepository.initialAutofillDialogShown &&
vaultAddEditType is VaultAddEditType.AddItem &&
autofillSelectionData == null
) {
@ -142,13 +142,12 @@ class VaultAddEditViewModel @Inject constructor(
is VaultAddEditType.AddItem -> {
autofillSelectionData
?.toDefaultAddTypeContent(isIndividualVaultDisabled)
?: autofillSaveItem
?.toDefaultAddTypeContent(isIndividualVaultDisabled)
?: fido2CreationRequest
?.toDefaultAddTypeContent(
?: 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 -> {
if (state.shouldExitOnSave) {
specialCircumstanceManager.specialCircumstance = null
sendEvent(
event = VaultAddEditEvent.ExitApp,
)
if (state.shouldExitOnSave) {
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(
(cipherView
?.toViewState(
isClone = isCloneMode,
isIndividualVaultDisabled = isIndividualVaultDisabled,
totpData = totpData,
resourceManager = resourceManager,
clock = clock,
) ?: viewState)
)
?: 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 {
/**

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.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(),

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.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

View file

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

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.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<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.
*/
@ -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.
*/

View file

@ -605,6 +605,7 @@ Scanning will happen automatically.</string>
<string name="thirty_days">30 days</string>
<string name="custom">Custom</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="about_send">About Send</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.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")

View file

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

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.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

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.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,

View file

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