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