From 5ec0a1986dd607a54fe4edc063ec9b176b32865b Mon Sep 17 00:00:00 2001 From: Nailik <13292441+Nailik@users.noreply.github.com> Date: Wed, 19 Nov 2025 23:00:53 +0100 Subject: [PATCH] [PM-24148] add credential manager provider for create passwords (#5579) Co-authored-by: Patrick Honkonen Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> --- app/src/main/AndroidManifest.xml | 1 + .../CredentialManagerPendingIntentManager.kt | 7 + ...edentialManagerPendingIntentManagerImpl.kt | 19 ++ .../model/CreateCredentialRequest.kt | 10 + .../CredentialProviderProcessorImpl.kt | 78 +++++-- .../CredentialProviderCompletionManager.kt | 8 +- ...CredentialProviderCompletionManagerImpl.kt | 19 +- ...iderCompletionManagerUnsupportedApiImpl.kt | 4 +- .../manager/model/CreateCredentialResult.kt | 34 +++ .../model/RegisterFido2CredentialResult.kt | 23 -- ...nOverwriteCredentialConfirmationDialog.kt} | 11 +- .../platform/feature/rootnav/RootNavScreen.kt | 13 ++ .../feature/rootnav/RootNavViewModel.kt | 46 +++- .../feature/addedit/VaultAddEditScreen.kt | 28 ++- .../feature/addedit/VaultAddEditViewModel.kt | 206 +++++++++--------- .../util/CreateCredentialRequestExtensions.kt | 11 +- .../itemlisting/VaultItemListingScreen.kt | 14 +- .../itemlisting/VaultItemListingViewModel.kt | 93 ++++++-- .../model/CreateCredentialRequestUtil.kt | 2 +- .../CredentialProviderProcessorTest.kt | 194 ++++++++++++++++- .../datasource/sdk/model/CipherViewUtil.kt | 2 +- ...CredentialProviderCompletionManagerTest.kt | 40 +++- .../feature/rootnav/RootNavScreenTest.kt | 25 +++ .../feature/rootnav/RootNavViewModelTest.kt | 80 +++++++ .../feature/addedit/VaultAddEditScreenTest.kt | 20 +- .../addedit/VaultAddEditViewModelTest.kt | 118 +++------- .../itemlisting/VaultItemListingScreenTest.kt | 12 +- .../VaultItemListingViewModelTest.kt | 94 +++++--- ui/src/main/res/values/strings.xml | 3 + 29 files changed, 879 insertions(+), 336 deletions(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/credentials/manager/model/CreateCredentialResult.kt delete mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/credentials/manager/model/RegisterFido2CredentialResult.kt rename app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/components/dialog/{BitwardenOverwritePasskeyConfirmationDialog.kt => BitwardenOverwriteCredentialConfirmationDialog.kt} (74%) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5aedb9ea27..27dbec1fbe 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -86,6 +86,7 @@ + diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/manager/CredentialManagerPendingIntentManager.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/manager/CredentialManagerPendingIntentManager.kt index b5488115f7..278c90a286 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/manager/CredentialManagerPendingIntentManager.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/manager/CredentialManagerPendingIntentManager.kt @@ -58,6 +58,13 @@ interface CredentialManagerPendingIntentManager { userId: String, ): PendingIntent + /** + * Creates a pending intent to use when providing options for Password credential creation. + */ + fun createPasswordCreationPendingIntent( + userId: String, + ): PendingIntent + /** * Creates a pending intent to use when providing options for Password credential filling. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/manager/CredentialManagerPendingIntentManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/manager/CredentialManagerPendingIntentManagerImpl.kt index 11690987f8..beb5538f55 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/manager/CredentialManagerPendingIntentManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/manager/CredentialManagerPendingIntentManagerImpl.kt @@ -75,6 +75,24 @@ class CredentialManagerPendingIntentManagerImpl( ) } + /** + * Creates a pending intent to use when providing options for FIDO 2 credential creation. + */ + override fun createPasswordCreationPendingIntent( + userId: String, + ): PendingIntent { + val intent = Intent(CREATE_PASSWORD_ACTION) + .setPackage(context.packageName) + .putExtra(EXTRA_KEY_USER_ID, userId) + + return PendingIntent.getActivity( + /* context = */ context, + /* requestCode = */ Random.nextInt(), + /* intent = */ intent, + /* flags = */ PendingIntent.FLAG_UPDATE_CURRENT.toPendingIntentMutabilityFlag(), + ) + } + /** * Creates a pending intent to use when providing options for Password credential filling. */ @@ -101,4 +119,5 @@ class CredentialManagerPendingIntentManagerImpl( private const val CREATE_PASSKEY_ACTION = "com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSKEY" private const val UNLOCK_ACCOUNT_ACTION = "com.x8bit.bitwarden.credentials.ACTION_UNLOCK_ACCOUNT" private const val GET_PASSKEY_ACTION = "com.x8bit.bitwarden.credentials.ACTION_GET_PASSKEY" +private const val CREATE_PASSWORD_ACTION = "com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSWORD" private const val GET_PASSWORD_ACTION = "com.x8bit.bitwarden.credentials.ACTION_GET_PASSWORD" diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/model/CreateCredentialRequest.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/model/CreateCredentialRequest.kt index 559ea698ca..0e6e71d82b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/model/CreateCredentialRequest.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/model/CreateCredentialRequest.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.credentials.model import android.os.Bundle import android.os.Parcelable +import androidx.credentials.CreatePasswordRequest import androidx.credentials.CreatePublicKeyCredentialRequest import androidx.credentials.provider.CallingAppInfo import androidx.credentials.provider.ProviderCreateCredentialRequest @@ -48,6 +49,15 @@ data class CreateCredentialRequest( providerRequest.callingRequest as? CreatePublicKeyCredentialRequest } + /** + * The [CreatePasswordRequest] of the [providerRequest], or null if the calling + * request is not a [CreatePasswordRequest]. + */ + @IgnoredOnParcel + val createPasswordCredentialRequest: CreatePasswordRequest? by lazy { + providerRequest.callingRequest as? CreatePasswordRequest + } + /** * The [requestJson] of the [createPublicKeyCredentialRequest], or null if the calling request * is not a [CreatePublicKeyCredentialRequest]. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/processor/CredentialProviderProcessorImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/processor/CredentialProviderProcessorImpl.kt index 0641d6f8d2..98b9245a88 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/processor/CredentialProviderProcessorImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/processor/CredentialProviderProcessorImpl.kt @@ -19,6 +19,7 @@ import androidx.credentials.exceptions.GetCredentialUnknownException import androidx.credentials.provider.AuthenticationAction import androidx.credentials.provider.BeginCreateCredentialRequest import androidx.credentials.provider.BeginCreateCredentialResponse +import androidx.credentials.provider.BeginCreatePasswordCredentialRequest import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest import androidx.credentials.provider.BeginGetCredentialRequest import androidx.credentials.provider.BeginGetCredentialResponse @@ -69,7 +70,7 @@ class CredentialProviderProcessorImpl( } val createCredentialJob = ioScope.launch { - processCreateCredentialRequest(request = request) + (handleCreatePasskeyQuery(request) ?: handleCreatePasswordQuery(request)) ?.let { callback.onResult(it) } ?: callback.onError(CreateCredentialUnknownException()) } @@ -137,21 +138,11 @@ class CredentialProviderProcessorImpl( callback.onError(ClearCredentialUnsupportedException()) } - private fun processCreateCredentialRequest( + private fun handleCreatePasskeyQuery( request: BeginCreateCredentialRequest, ): BeginCreateCredentialResponse? { - return when (request) { - is BeginCreatePublicKeyCredentialRequest -> { - handleCreatePasskeyQuery(request) - } + if (request !is BeginCreatePublicKeyCredentialRequest) return null - else -> null - } - } - - private fun handleCreatePasskeyQuery( - request: BeginCreatePublicKeyCredentialRequest, - ): BeginCreateCredentialResponse? { val requestJson = request .candidateQueryData .getString("androidx.credentials.BUNDLE_KEY_REQUEST_JSON") @@ -161,14 +152,19 @@ class CredentialProviderProcessorImpl( val userState = authRepository.userStateFlow.value ?: return null return BeginCreateCredentialResponse.Builder() - .setCreateEntries(userState.accounts.toCreateEntries(userState.activeUserId)) + .setCreateEntries( + userState.accounts.toCreatePasskeyEntry(userState.activeUserId), + ) .build() } - private fun List.toCreateEntries(activeUserId: String) = - map { it.toCreateEntry(isActive = activeUserId == it.userId) } + private fun List.toCreatePasskeyEntry( + activeUserId: String, + ): List = map { it.toCreatePasskeyEntry(isActive = activeUserId == it.userId) } - private fun UserState.Account.toCreateEntry(isActive: Boolean): CreateEntry { + private fun UserState.Account.toCreatePasskeyEntry( + isActive: Boolean, + ): CreateEntry { val accountName = name ?: email val entryBuilder = CreateEntry .Builder( @@ -196,6 +192,54 @@ class CredentialProviderProcessorImpl( return entryBuilder.build() } + private fun handleCreatePasswordQuery( + request: BeginCreateCredentialRequest, + ): BeginCreateCredentialResponse? { + if (request !is BeginCreatePasswordCredentialRequest) return null + + val userState = authRepository.userStateFlow.value ?: return null + + return BeginCreateCredentialResponse.Builder() + .setCreateEntries( + userState.accounts.toCreatePasswordEntry(userState.activeUserId), + ) + .build() + } + + private fun List.toCreatePasswordEntry( + activeUserId: String, + ) = map { it.toCreatePasswordEntry(isActive = activeUserId == it.userId) } + + private fun UserState.Account.toCreatePasswordEntry( + isActive: Boolean, + ): CreateEntry { + val accountName = name ?: email + val entryBuilder = CreateEntry + .Builder( + accountName = accountName, + pendingIntent = pendingIntentManager.createPasswordCreationPendingIntent( + userId = userId, + ), + ) + .setDescription( + context.getString( + BitwardenString.your_password_will_be_saved_to_your_bitwarden_vault_for_x, + accountName, + ), + ) + // Set the last used time to "now" so the active account is the default option in the + // system prompt. + .setLastUsedTime(if (isActive) clock.instant() else null) + .setAutoSelectAllowed(true) + + if (isVaultUnlocked) { + biometricsEncryptionManager + .getOrCreateCipher(userId) + ?.let { entryBuilder.setBiometricPromptDataIfSupported(cipher = it) } + } + return entryBuilder.build() + } + private fun CreateEntry.Builder.setBiometricPromptDataIfSupported( cipher: Cipher, ): CreateEntry.Builder { diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/credentials/manager/CredentialProviderCompletionManager.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/credentials/manager/CredentialProviderCompletionManager.kt index 0653b17579..d51711ac06 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/credentials/manager/CredentialProviderCompletionManager.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/credentials/manager/CredentialProviderCompletionManager.kt @@ -1,19 +1,19 @@ package com.x8bit.bitwarden.ui.credentials.manager import com.x8bit.bitwarden.ui.credentials.manager.model.AssertFido2CredentialResult +import com.x8bit.bitwarden.ui.credentials.manager.model.CreateCredentialResult import com.x8bit.bitwarden.ui.credentials.manager.model.GetCredentialsResult import com.x8bit.bitwarden.ui.credentials.manager.model.GetPasswordCredentialResult -import com.x8bit.bitwarden.ui.credentials.manager.model.RegisterFido2CredentialResult /** - * A manager for completing the FIDO 2 creation process. + * A manager for completing the credential creation process. */ interface CredentialProviderCompletionManager { /** - * Completes the FIDO 2 registration process with the provided [result]. + * Completes the credential registration process with the provided [result]. */ - fun completeFido2Registration(result: RegisterFido2CredentialResult) + fun completeCredentialRegistration(result: CreateCredentialResult) /** * Complete the FIDO 2 credential assertion process with the provided [result]. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/credentials/manager/CredentialProviderCompletionManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/credentials/manager/CredentialProviderCompletionManagerImpl.kt index 5a85ef087a..c40a99ef41 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/credentials/manager/CredentialProviderCompletionManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/credentials/manager/CredentialProviderCompletionManagerImpl.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.content.Intent import android.os.Build import androidx.annotation.RequiresApi +import androidx.credentials.CreatePasswordResponse import androidx.credentials.CreatePublicKeyCredentialResponse import androidx.credentials.GetCredentialResponse import androidx.credentials.PasswordCredential @@ -15,9 +16,9 @@ import androidx.credentials.exceptions.GetCredentialUnknownException import androidx.credentials.provider.BeginGetCredentialResponse import androidx.credentials.provider.PendingIntentHandler import com.x8bit.bitwarden.ui.credentials.manager.model.AssertFido2CredentialResult +import com.x8bit.bitwarden.ui.credentials.manager.model.CreateCredentialResult import com.x8bit.bitwarden.ui.credentials.manager.model.GetCredentialsResult import com.x8bit.bitwarden.ui.credentials.manager.model.GetPasswordCredentialResult -import com.x8bit.bitwarden.ui.credentials.manager.model.RegisterFido2CredentialResult /** * Primary implementation of [CredentialProviderCompletionManager] when the build version is @@ -28,11 +29,11 @@ class CredentialProviderCompletionManagerImpl( private val activity: Activity, ) : CredentialProviderCompletionManager { - override fun completeFido2Registration(result: RegisterFido2CredentialResult) { + override fun completeCredentialRegistration(result: CreateCredentialResult) { activity.also { val intent = Intent() when (result) { - is RegisterFido2CredentialResult.Error -> { + is CreateCredentialResult.Error -> { PendingIntentHandler .setCreateCredentialException( intent = intent, @@ -42,7 +43,7 @@ class CredentialProviderCompletionManagerImpl( ) } - is RegisterFido2CredentialResult.Success -> { + is CreateCredentialResult.Success.Fido2CredentialRegistered -> { PendingIntentHandler .setCreateCredentialResponse( intent = intent, @@ -52,7 +53,15 @@ class CredentialProviderCompletionManagerImpl( ) } - is RegisterFido2CredentialResult.Cancelled -> { + is CreateCredentialResult.Success.PasswordCreated -> { + PendingIntentHandler + .setCreateCredentialResponse( + intent = intent, + response = CreatePasswordResponse(), + ) + } + + is CreateCredentialResult.Cancelled -> { PendingIntentHandler .setCreateCredentialException( intent = intent, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/credentials/manager/CredentialProviderCompletionManagerUnsupportedApiImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/credentials/manager/CredentialProviderCompletionManagerUnsupportedApiImpl.kt index 2ba187dd7c..e16b5b4bc9 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/credentials/manager/CredentialProviderCompletionManagerUnsupportedApiImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/credentials/manager/CredentialProviderCompletionManagerUnsupportedApiImpl.kt @@ -3,9 +3,9 @@ package com.x8bit.bitwarden.ui.credentials.manager import androidx.credentials.CredentialProvider import com.bitwarden.annotation.OmitFromCoverage import com.x8bit.bitwarden.ui.credentials.manager.model.AssertFido2CredentialResult +import com.x8bit.bitwarden.ui.credentials.manager.model.CreateCredentialResult import com.x8bit.bitwarden.ui.credentials.manager.model.GetCredentialsResult import com.x8bit.bitwarden.ui.credentials.manager.model.GetPasswordCredentialResult -import com.x8bit.bitwarden.ui.credentials.manager.model.RegisterFido2CredentialResult /** * A no-op implementation of [CredentialProviderCompletionManagerImpl] provided when the build @@ -13,7 +13,7 @@ import com.x8bit.bitwarden.ui.credentials.manager.model.RegisterFido2CredentialR */ @OmitFromCoverage object CredentialProviderCompletionManagerUnsupportedApiImpl : CredentialProviderCompletionManager { - override fun completeFido2Registration(result: RegisterFido2CredentialResult) = Unit + override fun completeCredentialRegistration(result: CreateCredentialResult) = Unit override fun completeFido2Assertion(result: AssertFido2CredentialResult) = Unit diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/credentials/manager/model/CreateCredentialResult.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/credentials/manager/model/CreateCredentialResult.kt new file mode 100644 index 0000000000..c73bc5fcf7 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/credentials/manager/model/CreateCredentialResult.kt @@ -0,0 +1,34 @@ +package com.x8bit.bitwarden.ui.credentials.manager.model + +import com.bitwarden.ui.util.Text + +/** + * Represents the result of a credential creation attempt. + */ +sealed class CreateCredentialResult { + + /** + * Represents a successful credential creation attempt. + */ + sealed class Success : CreateCredentialResult() { + /** + * Indicates that the FIDO2 registration was successful. + */ + data class Fido2CredentialRegistered(val responseJson: String) : Success() + + /** + * Indicates that the Password creation was successful. + */ + data object PasswordCreated : Success() + } + + /** + * Indicates that an error occurred during credential creation. + */ + data class Error(val message: Text) : CreateCredentialResult() + + /** + * Indicates that credential creation was cancelled by the user. + */ + data object Cancelled : CreateCredentialResult() +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/credentials/manager/model/RegisterFido2CredentialResult.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/credentials/manager/model/RegisterFido2CredentialResult.kt deleted file mode 100644 index d5b4b685b1..0000000000 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/credentials/manager/model/RegisterFido2CredentialResult.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.x8bit.bitwarden.ui.credentials.manager.model - -import com.bitwarden.ui.util.Text - -/** - * Represents the result of a FIDO2 credential registration attempt. - */ -sealed class RegisterFido2CredentialResult { - /** - * Indicates that the registration was successful. - */ - data class Success(val responseJson: String) : RegisterFido2CredentialResult() - - /** - * Indicates that an error occurred during registration. - */ - data class Error(val message: Text) : RegisterFido2CredentialResult() - - /** - * Indicates that the registration was cancelled by the user. - */ - data object Cancelled : RegisterFido2CredentialResult() -} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenOverwritePasskeyConfirmationDialog.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenOverwriteCredentialConfirmationDialog.kt similarity index 74% rename from app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenOverwritePasskeyConfirmationDialog.kt rename to app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenOverwriteCredentialConfirmationDialog.kt index a21d576c35..ea8f4ccd3a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenOverwritePasskeyConfirmationDialog.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenOverwriteCredentialConfirmationDialog.kt @@ -6,21 +6,22 @@ import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog import com.bitwarden.ui.platform.resource.BitwardenString /** - * A reusable dialog for confirming whether or not the user wants to overwrite an existing FIDO 2 - * credential. + * A reusable dialog for confirming whether or not the user wants to overwrite an existing credential. * * @param onConfirmClick A callback for when the overwrite confirmation button is clicked. * @param onDismissRequest A callback for when the dialog is requesting dismissal. */ @Suppress("MaxLineLength") @Composable -fun BitwardenOverwritePasskeyConfirmationDialog( +fun BitwardenOverwriteCredentialConfirmationDialog( + title: String, + message: String, onConfirmClick: () -> Unit, onDismissRequest: () -> Unit, ) { BitwardenTwoButtonDialog( - title = stringResource(id = BitwardenString.overwrite_passkey), - message = stringResource(id = BitwardenString.this_item_already_contains_a_passkey_are_you_sure_you_want_to_overwrite_the_current_passkey), + title = title, + message = message, confirmButtonText = stringResource(id = BitwardenString.okay), dismissButtonText = stringResource(id = BitwardenString.cancel), onConfirmClick = onConfirmClick, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt index 82cc882f09..5d05d6fe6c 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt @@ -71,6 +71,7 @@ import com.x8bit.bitwarden.ui.vault.feature.exportitems.navigateToExportItemsGra import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.navigateToVerifyPassword import com.x8bit.bitwarden.ui.vault.feature.itemlisting.navigateToVaultItemListingAsRoot import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType +import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType import java.util.concurrent.atomic.AtomicReference @@ -141,6 +142,7 @@ fun RootNavScreen( is RootNavState.VaultUnlockedForFido2Assertion, is RootNavState.VaultUnlockedForPasswordGet, is RootNavState.VaultUnlockedForProviderGetCredentials, + is RootNavState.VaultUnlockedForCreatePasswordRequest, -> VaultUnlockedGraphRoute is RootNavState.CredentialExchangeExport, @@ -245,6 +247,17 @@ fun RootNavScreen( ) } + is RootNavState.VaultUnlockedForCreatePasswordRequest -> { + navController.navigateToVaultUnlockedGraph(rootNavOptions) + navController.navigateToVaultAddEdit( + args = VaultAddEditArgs( + vaultAddEditType = VaultAddEditType.AddItem, + vaultItemCipherType = VaultItemCipherType.LOGIN, + ), + navOptions = rootNavOptions, + ) + } + is RootNavState.VaultUnlockedForAutofillSelection -> { navController.navigateToVaultUnlockedGraph(rootNavOptions) navController.navigateToVaultItemListingAsRoot( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt index 623f3185fd..ec7b3fb0a5 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.bitwarden.network.model.OrganizationType import com.bitwarden.network.util.parseJwtTokenDataOrNull import com.bitwarden.ui.platform.base.BaseViewModel +import com.bitwarden.ui.platform.base.util.toAndroidAppUriString import com.bitwarden.ui.platform.manager.share.model.ShareData import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.repository.AuthRepository @@ -144,11 +145,31 @@ class RootNavViewModel @Inject constructor( } is SpecialCircumstance.ProviderCreateCredential -> { - RootNavState.VaultUnlockedForFido2Save( - activeUserId = userState.activeUserId, - createCredentialRequest = - specialCircumstance.createCredentialRequest, - ) + val request = specialCircumstance.createCredentialRequest + val publicKeyRequest = request.createPublicKeyCredentialRequest + val passwordRequest = request.createPasswordCredentialRequest + + when { + publicKeyRequest != null -> { + RootNavState.VaultUnlockedForFido2Save( + activeUserId = userState.activeUserId, + createCredentialRequest = request, + ) + } + + passwordRequest != null -> { + RootNavState.VaultUnlockedForCreatePasswordRequest( + username = passwordRequest.id, + password = passwordRequest.password, + uri = request + .callingAppInfo + .packageName + .toAndroidAppUriString(), + ) + } + + else -> throw IllegalStateException("Should not have entered here.") + } } is SpecialCircumstance.Fido2Assertion -> { @@ -336,6 +357,21 @@ sealed class RootNavState : Parcelable { val createCredentialRequest: CreateCredentialRequest, ) : RootNavState() + /** + * App should show an add item screen for a user to complete the saving of data collected by + * the credential manager framework. + * + * @param username The username of the user. + * @param password The password of the user. + * @param uri The URI to associate this credential with. + */ + @Parcelize + data class VaultUnlockedForCreatePasswordRequest( + val username: String, + val password: String, + val uri: String, + ) : RootNavState() + /** * App should perform FIDO 2 credential assertion for the user. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt index 3a5b35ed31..fc9daa1c1c 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt @@ -71,7 +71,7 @@ import com.bitwarden.ui.platform.theme.BitwardenTheme import com.bitwarden.ui.util.Text import com.x8bit.bitwarden.ui.credentials.manager.CredentialProviderCompletionManager import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenMasterPasswordDialog -import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenOverwritePasskeyConfirmationDialog +import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenOverwriteCredentialConfirmationDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenPinDialog import com.x8bit.bitwarden.ui.platform.composition.LocalBiometricsManager import com.x8bit.bitwarden.ui.platform.composition.LocalCredentialProviderCompletionManager @@ -161,8 +161,8 @@ fun VaultAddEditScreen( ) } - is VaultAddEditEvent.CompleteFido2Registration -> { - credentialProviderCompletionManager.completeFido2Registration( + is VaultAddEditEvent.CompleteCredentialRegistration -> { + credentialProviderCompletionManager.completeCredentialRegistration( result = event.result, ) } @@ -227,10 +227,14 @@ fun VaultAddEditScreen( onAutofillDismissRequest = remember(viewModel) { { viewModel.trySendAction(VaultAddEditAction.Common.InitialAutofillDialogDismissed) } }, - onFido2ErrorDismiss = remember(viewModel) { + onCredentialErrorDismiss = remember(viewModel) { { errorMessage -> viewModel.trySendAction( - VaultAddEditAction.Common.Fido2ErrorDialogDismissed(message = errorMessage), + VaultAddEditAction + .Common + .CredentialErrorDialogDismissed( + message = errorMessage, + ), ) } }, @@ -463,7 +467,7 @@ private fun VaultAddEditItemDialogs( dialogState: VaultAddEditState.DialogState?, onDismissRequest: () -> Unit, onAutofillDismissRequest: () -> Unit, - onFido2ErrorDismiss: (Text) -> Unit, + onCredentialErrorDismiss: (Text) -> Unit, onConfirmOverwriteExistingPasskey: () -> Unit, onSubmitMasterPasswordFido2Verification: (password: String) -> Unit, onRetryFido2PasswordVerification: () -> Unit, @@ -495,16 +499,22 @@ private fun VaultAddEditItemDialogs( ) } - is VaultAddEditState.DialogState.Fido2Error -> { + is VaultAddEditState.DialogState.CredentialError -> { BitwardenBasicDialog( title = stringResource(id = BitwardenString.an_error_has_occurred), message = dialogState.message(), - onDismissRequest = { onFido2ErrorDismiss(dialogState.message) }, + onDismissRequest = { onCredentialErrorDismiss(dialogState.message) }, ) } is VaultAddEditState.DialogState.OverwritePasskeyConfirmationPrompt -> { - BitwardenOverwritePasskeyConfirmationDialog( + @Suppress("MaxLineLength") + BitwardenOverwriteCredentialConfirmationDialog( + title = stringResource(id = BitwardenString.overwrite_passkey), + message = stringResource( + id = BitwardenString + .this_item_already_contains_a_passkey_are_you_sure_you_want_to_overwrite_the_current_passkey, + ), onConfirmClick = onConfirmOverwriteExistingPasskey, onDismissRequest = onDismissRequest, ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt index 876faaf1f5..bff43daecf 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt @@ -3,7 +3,6 @@ package com.x8bit.bitwarden.ui.vault.feature.addedit import android.os.Parcelable import androidx.credentials.CreatePublicKeyCredentialRequest import androidx.credentials.provider.CallingAppInfo -import androidx.credentials.provider.ProviderCreateCredentialRequest import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.bitwarden.core.DateTime @@ -34,7 +33,6 @@ import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest import com.x8bit.bitwarden.data.credentials.model.Fido2RegisterCredentialResult import com.x8bit.bitwarden.data.credentials.model.UserVerificationRequirement -import com.x8bit.bitwarden.data.credentials.util.getCreatePasskeyCredentialRequestOrNull import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager @@ -59,7 +57,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.VaultData -import com.x8bit.bitwarden.ui.credentials.manager.model.RegisterFido2CredentialResult +import com.x8bit.bitwarden.ui.credentials.manager.model.CreateCredentialResult import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode @@ -150,7 +148,7 @@ class VaultAddEditViewModel @Inject constructor( val autofillSelectionData = specialCircumstance?.toAutofillSelectionDataOrNull() // Check for totp data to pre-populate val totpData = specialCircumstance?.toTotpDataOrNull() - // Check for Fido2 data to pre-populate + // Check for Fido2 or Password credential data to pre-populate val providerCreateCredentialRequest = specialCircumstance?.toCreateCredentialRequestOrNull() val fido2AttestationOptions = providerCreateCredentialRequest?.requestJson @@ -348,8 +346,8 @@ class VaultAddEditViewModel @Inject constructor( handleUserVerificationCancelled() } - is VaultAddEditAction.Common.Fido2ErrorDialogDismissed -> { - handleFido2ErrorDialogDismissed(action) + is VaultAddEditAction.Common.CredentialErrorDialogDismissed -> { + handleCredentialErrorDialogDismissed(action) } VaultAddEditAction.Common.UserVerificationNotSupported -> { @@ -401,31 +399,7 @@ class VaultAddEditViewModel @Inject constructor( @Suppress("LongMethod") private fun handleSaveClick() = onContent { content -> - if (content.common.name.isBlank()) { - showGenericErrorDialog( - message = BitwardenString.validation_field_required - .asText(BitwardenString.name.asText()), - ) - return@onContent - } else if ( - content.common.selectedOwnerId != null && - content.common.selectedOwner?.collections?.all { !it.isSelected } == true - ) { - showGenericErrorDialog( - message = BitwardenString.select_one_collection.asText(), - ) - return@onContent - } else if ( - !networkConnectionManager.isNetworkConnected - ) { - showDialog( - dialogState = VaultAddEditState.DialogState.Generic( - title = BitwardenString.internet_connection_required_title.asText(), - message = BitwardenString.internet_connection_required_message.asText(), - ), - ) - return@onContent - } + if (hasValidationErrors(content)) return@onContent mutableStateFlow.update { it.copy( @@ -435,14 +409,17 @@ class VaultAddEditViewModel @Inject constructor( ) } - state.createCredentialRequest - ?.let { request -> - handleProviderCreateCredentialRequest( - request.providerRequest, - content.toCipherView(), - ) - return@onContent - } + state.createCredentialRequest?.run { + createPublicKeyCredentialRequest + ?.let { createPublicKeyCredentialRequest -> + handleCreatePublicKeyCredentialRequest( + request = createPublicKeyCredentialRequest, + callingAppInfo = this.callingAppInfo, + cipherView = content.toCipherView(), + ) + return@onContent + } + } viewModelScope.launch { when (val vaultAddEditType = state.vaultAddEditType) { @@ -467,15 +444,34 @@ class VaultAddEditViewModel @Inject constructor( } } - private fun handleProviderCreateCredentialRequest( - request: ProviderCreateCredentialRequest, - cipherView: CipherView, - ) { - request - .getCreatePasskeyCredentialRequestOrNull() - ?.let { handleCreatePublicKeyCredentialRequest(request.callingAppInfo, it, cipherView) } - ?: run { handleUnsupportedProviderCreateCredentialRequest() } - } + private fun hasValidationErrors(content: VaultAddEditState.ViewState.Content): Boolean = + if (content.common.name.isBlank()) { + showGenericErrorDialog( + message = BitwardenString.validation_field_required + .asText(BitwardenString.name.asText()), + ) + true + } else if ( + content.common.selectedOwnerId != null && + content.common.selectedOwner?.collections?.all { !it.isSelected } == true + ) { + showGenericErrorDialog( + message = BitwardenString.select_one_collection.asText(), + ) + true + } else if ( + !networkConnectionManager.isNetworkConnected + ) { + showDialog( + dialogState = VaultAddEditState.DialogState.Generic( + title = BitwardenString.internet_connection_required_title.asText(), + message = BitwardenString.internet_connection_required_message.asText(), + ), + ) + true + } else { + false + } private fun handleCreatePublicKeyCredentialRequest( callingAppInfo: CallingAppInfo, @@ -520,7 +516,7 @@ class VaultAddEditViewModel @Inject constructor( viewModelScope.launch { val userId = authRepository.activeUserId ?: run { - showFido2ErrorDialog( + showCredentialErrorDialog( BitwardenString.passkey_operation_failed_because_user_could_not_be_verified .asText(), ) @@ -539,12 +535,6 @@ class VaultAddEditViewModel @Inject constructor( } } - private fun handleUnsupportedProviderCreateCredentialRequest() { - showFido2ErrorDialog( - BitwardenString.passkey_operation_failed_because_the_request_is_unsupported.asText(), - ) - } - private fun handleAttachmentsClick() { onEdit { sendEvent(VaultAddEditEvent.NavigateToAttachments(it.vaultItemId)) } } @@ -613,63 +603,69 @@ class VaultAddEditViewModel @Inject constructor( private fun handleConfirmOverwriteExistingPasskeyClick() { state .createCredentialRequest - ?.providerRequest ?.let { request -> - onContent { content -> - handleProviderCreateCredentialRequest( - request, - content.toCipherView(), - ) - } + request.createPublicKeyCredentialRequest + ?.let { createPublicKeyCredentialRequest -> + onContent { content -> + handleCreatePublicKeyCredentialRequest( + request = createPublicKeyCredentialRequest, + callingAppInfo = request.callingAppInfo, + cipherView = content.toCipherView(), + ) + } + } } - ?: showFido2ErrorDialog( + ?: showCredentialErrorDialog( BitwardenString.passkey_operation_failed_because_the_request_is_invalid.asText(), ) } private fun handleUserVerificationLockOut() { bitwardenCredentialManager.isUserVerified = false - showFido2ErrorDialog( + showCredentialErrorDialog( BitwardenString.passkey_operation_failed_because_user_could_not_be_verified.asText(), ) } private fun handleUserVerificationSuccess() { bitwardenCredentialManager.isUserVerified = true - getRequestAndRegisterCredential() + getRequestAndRegisterFido2Credential() } - private fun getRequestAndRegisterCredential() = + private fun getRequestAndRegisterFido2Credential() = state.createCredentialRequest - ?.providerRequest ?.let { request -> - onContent { content -> - handleProviderCreateCredentialRequest( - request = request, - cipherView = content.toCipherView(), - ) - } + request.createPublicKeyCredentialRequest + ?.let { createPublicKeyCredentialRequest -> + onContent { content -> + handleCreatePublicKeyCredentialRequest( + request = createPublicKeyCredentialRequest, + callingAppInfo = request.callingAppInfo, + cipherView = content.toCipherView(), + ) + } + } } - ?: showFido2ErrorDialog( + ?: showCredentialErrorDialog( BitwardenString.passkey_operation_failed_because_the_request_is_unsupported .asText(), ) private fun handleUserVerificationFail() { bitwardenCredentialManager.isUserVerified = false - showFido2ErrorDialog( + showCredentialErrorDialog( BitwardenString.passkey_operation_failed_because_user_could_not_be_verified.asText(), ) } - private fun handleFido2ErrorDialogDismissed( - action: VaultAddEditAction.Common.Fido2ErrorDialogDismissed, + private fun handleCredentialErrorDialogDismissed( + action: VaultAddEditAction.Common.CredentialErrorDialogDismissed, ) { bitwardenCredentialManager.isUserVerified = false clearDialogState() sendEvent( - VaultAddEditEvent.CompleteFido2Registration( - result = RegisterFido2CredentialResult.Error(action.message), + VaultAddEditEvent.CompleteCredentialRegistration( + result = CreateCredentialResult.Error(action.message), ), ) } @@ -678,8 +674,8 @@ class VaultAddEditViewModel @Inject constructor( bitwardenCredentialManager.isUserVerified = false clearDialogState() sendEvent( - VaultAddEditEvent.CompleteFido2Registration( - result = RegisterFido2CredentialResult.Cancelled, + VaultAddEditEvent.CompleteCredentialRegistration( + result = CreateCredentialResult.Cancelled, ), ) } @@ -692,7 +688,7 @@ class VaultAddEditViewModel @Inject constructor( .value ?.activeAccount ?: run { - showFido2ErrorDialog( + showCredentialErrorDialog( BitwardenString.passkey_operation_failed_because_user_could_not_be_verified .asText(), ) @@ -780,7 +776,7 @@ class VaultAddEditViewModel @Inject constructor( } private fun handleDismissFido2VerificationDialogClick() { - showFido2ErrorDialog( + showCredentialErrorDialog( BitwardenString.passkey_operation_failed_because_user_could_not_be_verified .asText(), ) @@ -1657,7 +1653,13 @@ class VaultAddEditViewModel @Inject constructor( if (state.shouldClearSpecialCircumstance) { specialCircumstanceManager.specialCircumstance = null } - if (state.shouldExitOnSave) { + if (state.createCredentialRequest?.createPasswordCredentialRequest != null) { + sendEvent( + VaultAddEditEvent.CompleteCredentialRegistration( + CreateCredentialResult.Success.PasswordCreated, + ), + ) + } else if (state.shouldExitOnSave) { sendEvent(event = VaultAddEditEvent.ExitApp) } else { snackbarRelayManager.sendSnackbarData( @@ -1970,8 +1972,8 @@ class VaultAddEditViewModel @Inject constructor( // Use toast here because we are closing the activity. toastManager.show(BitwardenString.an_error_has_occurred) sendEvent( - VaultAddEditEvent.CompleteFido2Registration( - RegisterFido2CredentialResult.Error( + VaultAddEditEvent.CompleteCredentialRegistration( + CreateCredentialResult.Error( action.result.messageResourceId.asText(), ), ), @@ -1982,8 +1984,10 @@ class VaultAddEditViewModel @Inject constructor( // Use toast here because we are closing the activity. toastManager.show(BitwardenString.item_updated) sendEvent( - VaultAddEditEvent.CompleteFido2Registration( - RegisterFido2CredentialResult.Success(action.result.responseJson), + VaultAddEditEvent.CompleteCredentialRegistration( + CreateCredentialResult.Success.Fido2CredentialRegistered( + responseJson = action.result.responseJson, + ), ), ) } @@ -1997,7 +2001,7 @@ class VaultAddEditViewModel @Inject constructor( when (action.result) { is ValidatePasswordResult.Error -> { - showFido2ErrorDialog( + showCredentialErrorDialog( BitwardenString.passkey_operation_failed_because_user_could_not_be_verified .asText(), ) @@ -2022,7 +2026,7 @@ class VaultAddEditViewModel @Inject constructor( when (action.result) { is ValidatePinResult.Error -> { - showFido2ErrorDialog( + showCredentialErrorDialog( BitwardenString.passkey_operation_failed_because_user_could_not_be_verified .asText(), ) @@ -2047,7 +2051,7 @@ class VaultAddEditViewModel @Inject constructor( it.copy(dialog = errorDialogState) } } else { - showFido2ErrorDialog( + showCredentialErrorDialog( BitwardenString.passkey_operation_failed_because_user_could_not_be_verified .asText(), ) @@ -2058,7 +2062,7 @@ class VaultAddEditViewModel @Inject constructor( bitwardenCredentialManager.isUserVerified = true bitwardenCredentialManager.authenticationAttempts = 0 - getRequestAndRegisterCredential() + getRequestAndRegisterFido2Credential() } private fun handleAuthenticatorHelpToolTipClick() { @@ -2072,10 +2076,10 @@ class VaultAddEditViewModel @Inject constructor( mutableStateFlow.update { it.copy(dialog = null) } } - private fun showFido2ErrorDialog(message: Text) { + private fun showCredentialErrorDialog(message: Text) { mutableStateFlow.update { it.copy( - dialog = VaultAddEditState.DialogState.Fido2Error(message), + dialog = VaultAddEditState.DialogState.CredentialError(message), ) } } @@ -2754,10 +2758,10 @@ data class VaultAddEditState( data object InitialAutofillPrompt : DialogState() /** - * Displays a FIDO 2 operation error dialog to the user. + * Displays a credential operation error dialog to the user. */ @Parcelize - data class Fido2Error(val message: Text) : DialogState() + data class CredentialError(val message: Text) : DialogState() /** * Displays the overwrite passkey confirmation prompt to the user. @@ -2888,12 +2892,12 @@ sealed class VaultAddEditEvent { ) : VaultAddEditEvent() /** - * Complete the current FIDO 2 credential registration process. + * Complete the current credential registration process. * * @property result the result of FIDO 2 credential registration. */ - data class CompleteFido2Registration( - val result: RegisterFido2CredentialResult, + data class CompleteCredentialRegistration( + val result: CreateCredentialResult, ) : BackgroundEvent, VaultAddEditEvent() /** @@ -3083,9 +3087,9 @@ sealed class VaultAddEditAction { data object UserVerificationCancelled : Common() /** - * The user has dismissed the FIDO 2 credential error dialog. + * The user has dismissed the credential error dialog. */ - data class Fido2ErrorDialogDismissed(val message: Text) : Common() + data class CredentialErrorDialogDismissed(val message: Text) : Common() /** * User verification cannot be performed with device biometrics or credentials. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CreateCredentialRequestExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CreateCredentialRequestExtensions.kt index 777713dd72..42a6adae2d 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CreateCredentialRequestExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CreateCredentialRequestExtensions.kt @@ -10,7 +10,7 @@ import java.util.UUID /** * Returns pre-filled content that may be used for an "add" type - * [VaultAddEditState.ViewState.Content] during FIDO 2 credential creation. + * [VaultAddEditState.ViewState.Content] during FIDO 2 or Password credential creation. */ fun CreateCredentialRequest.toDefaultAddTypeContent( attestationOptions: PasskeyAttestationOptions?, @@ -26,11 +26,17 @@ fun CreateCredentialRequest.toDefaultAddTypeContent( val rpName = attestationOptions ?.relyingParty ?.name - .orEmpty() + ?: callingAppInfo.packageName val username = attestationOptions ?.user ?.name + ?: createPasswordCredentialRequest + ?.id + .orEmpty() + + val password = createPasswordCredentialRequest + ?.password .orEmpty() return VaultAddEditState.ViewState.Content( @@ -40,6 +46,7 @@ fun CreateCredentialRequest.toDefaultAddTypeContent( isIndividualVaultDisabled = isIndividualVaultDisabled, type = VaultAddEditState.ViewState.Content.ItemType.Login( username = username, + password = password, uriList = listOf( UriItem( id = UUID.randomUUID().toString(), diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt index 26037a25ea..8e7938efad 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt @@ -50,7 +50,7 @@ import com.bitwarden.ui.util.Text import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.ui.credentials.manager.CredentialProviderCompletionManager import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenMasterPasswordDialog -import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenOverwritePasskeyConfirmationDialog +import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenOverwriteCredentialConfirmationDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenPinDialog import com.x8bit.bitwarden.ui.platform.composition.LocalBiometricsManager import com.x8bit.bitwarden.ui.platform.composition.LocalCredentialProviderCompletionManager @@ -179,8 +179,8 @@ fun VaultItemListingScreen( onNavigateToVaultItemListing(VaultItemListingType.Collection(event.collectionId)) } - is VaultItemListingEvent.CompleteFido2Registration -> { - credentialProviderCompletionManager.completeFido2Registration(event.result) + is VaultItemListingEvent.CompleteCredentialRegistration -> { + credentialProviderCompletionManager.completeCredentialRegistration(event.result) } is VaultItemListingEvent.CredentialManagerUserVerification -> { @@ -392,7 +392,13 @@ private fun VaultItemListingDialogs( ) is VaultItemListingState.DialogState.OverwritePasskeyConfirmationPrompt -> { - BitwardenOverwritePasskeyConfirmationDialog( + @Suppress("MaxLineLength") + BitwardenOverwriteCredentialConfirmationDialog( + title = stringResource(id = BitwardenString.overwrite_passkey), + message = stringResource( + id = BitwardenString + .this_item_already_contains_a_passkey_are_you_sure_you_want_to_overwrite_the_current_passkey, + ), onConfirmClick = { onConfirmOverwriteExistingPasskey(dialogState.cipherViewId) }, onDismissRequest = onDismissRequest, ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index 0718be7825..6d24a0cec9 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -2,6 +2,8 @@ package com.x8bit.bitwarden.ui.vault.feature.itemlisting import android.os.Parcelable import androidx.annotation.DrawableRes +import androidx.credentials.CreatePasswordRequest +import androidx.credentials.CreatePublicKeyCredentialRequest import androidx.credentials.GetPublicKeyCredentialOption import androidx.credentials.provider.CallingAppInfo import androidx.credentials.provider.CredentialEntry @@ -77,9 +79,9 @@ import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.ui.credentials.manager.model.AssertFido2CredentialResult +import com.x8bit.bitwarden.ui.credentials.manager.model.CreateCredentialResult import com.x8bit.bitwarden.ui.credentials.manager.model.GetCredentialsResult import com.x8bit.bitwarden.ui.credentials.manager.model.GetPasswordCredentialResult -import com.x8bit.bitwarden.ui.credentials.manager.model.RegisterFido2CredentialResult 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 @@ -168,9 +170,11 @@ class VaultItemListingViewModel @Inject constructor( baseIconUrl = environmentRepository.environment.environmentUrlData.baseIconUrl, isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled, isPullToRefreshSettingEnabled = settingsRepository.getPullToRefreshEnabledFlow().value, - dialogState = providerCreateCredentialRequest?.let { - VaultItemListingState.DialogState.Loading(BitwardenString.loading.asText()) - }, + dialogState = providerCreateCredentialRequest + ?.createPublicKeyCredentialRequest + ?.let { + VaultItemListingState.DialogState.Loading(BitwardenString.loading.asText()) + }, policyDisablesSend = policyManager .getActivePolicies(type = PolicyTypeJson.DISABLE_SEND) .any(), @@ -426,8 +430,8 @@ class VaultItemListingViewModel @Inject constructor( state.createCredentialRequest ?.let { sendEvent( - VaultItemListingEvent.CompleteFido2Registration( - result = RegisterFido2CredentialResult.Cancelled, + VaultItemListingEvent.CompleteCredentialRegistration( + result = CreateCredentialResult.Cancelled, ), ) } @@ -973,7 +977,7 @@ class VaultItemListingViewModel @Inject constructor( createCredentialRequest .providerRequest .getCreatePasskeyCredentialRequestOrNull() - ?.let { createPasskeyCredentialRequest -> + ?.let { handleItemClickForCreatePublicKeyCredentialRequest( cipherId = action.id, cipherView = cipherView, @@ -984,7 +988,7 @@ class VaultItemListingViewModel @Inject constructor( VaultItemListingsAction.Internal.CredentialOperationFailureReceive( title = BitwardenString.an_error_has_occurred.asText(), message = BitwardenString - .passkey_operation_failed_because_the_request_is_unsupported + .credential_operation_failed_because_the_request_is_unsupported .asText(), error = null, ), @@ -1085,6 +1089,27 @@ class VaultItemListingViewModel @Inject constructor( } } + private fun registerCredentialToCipher( + cipherView: CipherView, + providerRequest: ProviderCreateCredentialRequest, + ) { + when (providerRequest.callingRequest) { + is CreatePublicKeyCredentialRequest -> { + registerFido2CredentialToCipher( + cipherView = cipherView, + providerRequest = providerRequest, + ) + } + + else -> { + showCredentialManagerErrorDialog( + BitwardenString.credential_operation_failed_because_the_request_is_invalid + .asText(), + ) + } + } + } + private fun registerFido2CredentialToCipher( cipherView: CipherView, providerRequest: ProviderCreateCredentialRequest, @@ -1326,8 +1351,8 @@ class VaultItemListingViewModel @Inject constructor( when { state.createCredentialRequest != null -> { sendEvent( - VaultItemListingEvent.CompleteFido2Registration( - result = RegisterFido2CredentialResult.Error(action.message), + VaultItemListingEvent.CompleteCredentialRegistration( + result = CreateCredentialResult.Error(action.message), ), ) } @@ -1530,7 +1555,7 @@ class VaultItemListingViewModel @Inject constructor( } is VaultItemListingsAction.Internal.CreateCredentialRequestReceive -> { - handleRegisterFido2CredentialRequestReceive(action) + handleRegisterCredentialRequestReceive(action) } is VaultItemListingsAction.Internal.Fido2RegisterCredentialResultReceive -> { @@ -1900,7 +1925,7 @@ class VaultItemListingViewModel @Inject constructor( state.createCredentialRequest ?.providerRequest ?.let { request -> - registerFido2CredentialToCipher( + registerCredentialToCipher( cipherView = cipherView, providerRequest = request, ) @@ -2012,6 +2037,32 @@ class VaultItemListingViewModel @Inject constructor( } } + private fun handleRegisterCredentialRequestReceive( + action: VaultItemListingsAction.Internal.CreateCredentialRequestReceive, + ) { + when (action.request.providerRequest.callingRequest) { + is CreatePublicKeyCredentialRequest -> { + handleRegisterFido2CredentialRequestReceive(action) + } + + is CreatePasswordRequest -> { + observeVaultData() + } + + else -> { + mutableStateFlow.update { + it.copy( + dialogState = + VaultItemListingState.DialogState.CredentialManagerOperationFail( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString.generic_error_message.asText(), + ), + ) + } + } + } + } + private fun handleRegisterFido2CredentialRequestReceive( action: VaultItemListingsAction.Internal.CreateCredentialRequestReceive, ) { @@ -2062,8 +2113,10 @@ class VaultItemListingViewModel @Inject constructor( // user to have time to see the message. toastManager.show(messageId = BitwardenString.item_updated) sendEvent( - VaultItemListingEvent.CompleteFido2Registration( - RegisterFido2CredentialResult.Success(action.result.responseJson), + VaultItemListingEvent.CompleteCredentialRegistration( + CreateCredentialResult.Success.Fido2CredentialRegistered( + responseJson = action.result.responseJson, + ), ), ) } @@ -2077,8 +2130,8 @@ class VaultItemListingViewModel @Inject constructor( // user to have time to see the message. toastManager.show(messageId = BitwardenString.an_error_has_occurred) sendEvent( - VaultItemListingEvent.CompleteFido2Registration( - RegisterFido2CredentialResult.Error( + VaultItemListingEvent.CompleteCredentialRegistration( + CreateCredentialResult.Error( message = error.messageResourceId.asText(), ), ), @@ -3240,12 +3293,12 @@ sealed class VaultItemListingEvent { } /** - * Complete the current FIDO 2 credential registration process. + * Complete the current credential registration process. * - * @property result The result of FIDO 2 credential registration. + * @property result The result of the credential registration. */ - data class CompleteFido2Registration( - val result: RegisterFido2CredentialResult, + data class CompleteCredentialRegistration( + val result: CreateCredentialResult, ) : BackgroundEvent, VaultItemListingEvent() /** diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/model/CreateCredentialRequestUtil.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/model/CreateCredentialRequestUtil.kt index 939e9758b1..60539badce 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/model/CreateCredentialRequestUtil.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/model/CreateCredentialRequestUtil.kt @@ -11,7 +11,7 @@ fun createMockCreateCredentialRequest( isUserPreVerified: Boolean = false, requestData: Bundle = bundleOf(), ): CreateCredentialRequest = CreateCredentialRequest( - userId = "mockUserId-$number", + userId = "mockUserIdx-$number", isUserPreVerified = isUserPreVerified, requestData = requestData, ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/processor/CredentialProviderProcessorTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/processor/CredentialProviderProcessorTest.kt index 46a50a55a5..30bb3e9506 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/processor/CredentialProviderProcessorTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/processor/CredentialProviderProcessorTest.kt @@ -125,7 +125,7 @@ class CredentialProviderProcessorTest { @Suppress("MaxLineLength") @Test - fun `processCreateCredentialRequest should invoke callback with error on password create request`() { + fun `processCreateCredentialRequest should invoke callback with error on password create request when userState is null`() { val request: BeginCreatePasswordCredentialRequest = mockk { every { callingAppInfo } returns mockk(relaxed = true) every { candidateQueryData } returns Bundle() @@ -148,6 +148,198 @@ class CredentialProviderProcessorTest { assert(captureSlot.captured is CreateCredentialUnknownException) } + @Suppress("MaxLineLength") + @Test + fun `processCreateCredentialRequest should invoke callback with result on password create request with valid userState`() { + val request: BeginCreatePasswordCredentialRequest = mockk { + every { callingAppInfo } returns mockk(relaxed = true) + every { candidateQueryData } returns Bundle() + } + val callback: OutcomeReceiver = + mockk() + val captureSlot = slot() + val mockIntent: PendingIntent = mockk() + mutableUserStateFlow.value = DEFAULT_USER_STATE + every { context.packageName } returns "com.x8bit.bitwarden" + every { context.getString(any(), any()) } returns "mockDescription" + every { + pendingIntentManager.createPasswordCreationPendingIntent( + userId = any(), + ) + } returns mockIntent + every { + biometricsEncryptionManager.getOrCreateCipher(userId = any()) + } returns mockk() + every { cancellationSignal.setOnCancelListener(any()) } just runs + every { callback.onResult(capture(captureSlot)) } just runs + + credentialProviderProcessor.processCreateCredentialRequest( + request = request, + cancellationSignal = cancellationSignal, + callback = callback, + ) + + verify(exactly = 1) { callback.onResult(any()) } + verify(exactly = 0) { callback.onError(any()) } + + assertEquals(DEFAULT_USER_STATE.accounts.size, captureSlot.captured.createEntries.size) + val capturedEntry = captureSlot.captured.createEntries[0] + assertEquals(DEFAULT_USER_STATE.accounts[0].email, capturedEntry.accountName) + } + + @Suppress("MaxLineLength") + @Test + fun `processCreateCredentialRequest should generate correct password entries based on state`() { + val request: BeginCreatePasswordCredentialRequest = mockk { + every { callingAppInfo } returns mockk(relaxed = true) + every { candidateQueryData } returns Bundle() + } + val callback: OutcomeReceiver = + mockk() + mutableUserStateFlow.value = DEFAULT_USER_STATE + val captureSlot = slot() + val mockIntent: PendingIntent = mockk() + every { context.packageName } returns "com.x8bit.bitwarden.dev" + every { context.getString(any(), any()) } returns "mockDescription" + every { cancellationSignal.setOnCancelListener(any()) } just runs + every { callback.onResult(capture(captureSlot)) } just runs + every { + pendingIntentManager.createPasswordCreationPendingIntent( + userId = any(), + ) + } returns mockIntent + every { + biometricsEncryptionManager.getOrCreateCipher(any()) + } returns mockk() + every { isBuildVersionAtLeast(Build.VERSION_CODES.VANILLA_ICE_CREAM) } returns true + + credentialProviderProcessor.processCreateCredentialRequest( + request = request, + cancellationSignal = cancellationSignal, + callback = callback, + ) + + verify(exactly = 1) { callback.onResult(any()) } + verify(exactly = 0) { callback.onError(any()) } + + assertEquals(DEFAULT_USER_STATE.accounts.size, captureSlot.captured.createEntries.size) + + // Verify only the active account entry has a lastUsedTime + assertEquals( + 1, + captureSlot.captured.createEntries.filter { it.lastUsedTime != null }.size, + ) + DEFAULT_USER_STATE.accounts.forEachIndexed { index, mockAccount -> + assertEquals(mockAccount.email, captureSlot.captured.createEntries[index].accountName) + } + + // Verify all entries have biometric prompt data when feature flag is enabled + assertTrue(captureSlot.captured.createEntries.all { it.biometricPromptData != null }) { + "Expected all entries to have biometric prompt data." + } + + // Verify entries have the correct authenticators when cipher is not null + assertTrue( + captureSlot.captured + .createEntries + .all { + it.biometricPromptData?.allowedAuthenticators == + BiometricManager.Authenticators.BIOMETRIC_STRONG + }, + ) { "Expected all entries to have BIOMETRIC_STRONG authenticators." } + + // Verify entries have no biometric prompt data when cipher is null + every { biometricsEncryptionManager.getOrCreateCipher(any()) } returns null + credentialProviderProcessor.processCreateCredentialRequest( + request = request, + cancellationSignal = cancellationSignal, + callback = callback, + ) + assertTrue( + captureSlot.captured.createEntries.all { it.biometricPromptData == null }, + ) { "Expected all entries to have null biometric prompt data." } + } + + @Suppress("MaxLineLength") + @Test + fun `processCreateCredentialRequest should not add biometric data to password entries on pre-V devices`() { + val request: BeginCreatePasswordCredentialRequest = mockk { + every { callingAppInfo } returns mockk(relaxed = true) + every { candidateQueryData } returns Bundle() + } + val callback: OutcomeReceiver = + mockk() + val captureSlot = slot() + val mockIntent: PendingIntent = mockk() + mutableUserStateFlow.value = DEFAULT_USER_STATE + every { context.packageName } returns "com.x8bit.bitwarden" + every { context.getString(any(), any()) } returns "mockDescription" + every { + pendingIntentManager.createPasswordCreationPendingIntent( + userId = any(), + ) + } returns mockIntent + every { + biometricsEncryptionManager.getOrCreateCipher(userId = any()) + } returns mockk() + every { cancellationSignal.setOnCancelListener(any()) } just runs + every { callback.onResult(capture(captureSlot)) } just runs + every { isBuildVersionAtLeast(Build.VERSION_CODES.VANILLA_ICE_CREAM) } returns false + + credentialProviderProcessor.processCreateCredentialRequest( + request = request, + cancellationSignal = cancellationSignal, + callback = callback, + ) + + verify(exactly = 1) { callback.onResult(any()) } + + // Verify entries have no biometric prompt data on older devices + assertTrue(captureSlot.captured.createEntries.all { it.biometricPromptData == null }) { + "Expected all entries to have null biometric prompt data on pre-V devices." + } + } + + @Suppress("MaxLineLength") + @Test + fun `processCreateCredentialRequest should not add biometric data to password entries when vault is locked`() { + val request: BeginCreatePasswordCredentialRequest = mockk { + every { callingAppInfo } returns mockk(relaxed = true) + every { candidateQueryData } returns Bundle() + } + val callback: OutcomeReceiver = + mockk() + val captureSlot = slot() + val mockIntent: PendingIntent = mockk() + mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( + accounts = DEFAULT_USER_STATE.accounts.map { it.copy(isVaultUnlocked = false) }, + ) + every { context.packageName } returns "com.x8bit.bitwarden" + every { context.getString(any(), any()) } returns "mockDescription" + every { + pendingIntentManager.createPasswordCreationPendingIntent( + userId = any(), + ) + } returns mockIntent + every { cancellationSignal.setOnCancelListener(any()) } just runs + every { callback.onResult(capture(captureSlot)) } just runs + every { isBuildVersionAtLeast(Build.VERSION_CODES.VANILLA_ICE_CREAM) } returns true + + credentialProviderProcessor.processCreateCredentialRequest( + request = request, + cancellationSignal = cancellationSignal, + callback = callback, + ) + + verify(exactly = 1) { callback.onResult(any()) } + verify(exactly = 0) { biometricsEncryptionManager.getOrCreateCipher(any()) } + + // Verify entries have no biometric prompt data when vault is locked + assertTrue(captureSlot.captured.createEntries.all { it.biometricPromptData == null }) { + "Expected all entries to have null biometric prompt data when vault is locked." + } + } + @Suppress("MaxLineLength") @Test fun `processCreateCredentialRequest should invoke callback with error when json is null or empty`() { diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt index f32e44a186..e101a117d9 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt @@ -48,7 +48,7 @@ fun createMockCipherView( organizationId: String? = "mockOrganizationId-$number", folderId: String? = "mockId-$number", notes: String? = "mockNotes-$number", - password: String = "mockPassword-$number", + password: String? = "mockPassword-$number", clock: Clock = FIXED_CLOCK, fido2Credentials: List? = null, sshKey: SshKeyView? = createMockSshKeyView(number = number), diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/credentials/manager/CredentialProviderCompletionManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/credentials/manager/CredentialProviderCompletionManagerTest.kt index 0c231810dc..e5f9e2b3e4 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/credentials/manager/CredentialProviderCompletionManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/credentials/manager/CredentialProviderCompletionManagerTest.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.app.PendingIntent import android.content.Intent import android.graphics.drawable.Icon +import androidx.credentials.CreatePasswordResponse import androidx.credentials.exceptions.GetCredentialCancellationException import androidx.credentials.exceptions.GetCredentialUnknownException import androidx.credentials.provider.BeginGetCredentialResponse @@ -16,9 +17,9 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFido2Creden import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockLoginView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockPasswordCredentialAutofillCipherLogin import com.x8bit.bitwarden.ui.credentials.manager.model.AssertFido2CredentialResult +import com.x8bit.bitwarden.ui.credentials.manager.model.CreateCredentialResult import com.x8bit.bitwarden.ui.credentials.manager.model.GetCredentialsResult import com.x8bit.bitwarden.ui.credentials.manager.model.GetPasswordCredentialResult -import com.x8bit.bitwarden.ui.credentials.manager.model.RegisterFido2CredentialResult import io.mockk.Called import io.mockk.MockKVerificationScope import io.mockk.Ordering @@ -60,9 +61,11 @@ class CredentialProviderCompletionManagerTest { } @Test - fun `completeFido2Registration should perform no operations`() { - val mockRegistrationResult = mockk() - credentialProviderCompletionManager.completeFido2Registration(mockRegistrationResult) + fun `completeCredentialRegistration should perform no operations`() { + val mockRegistrationResult = mockk() + credentialProviderCompletionManager.completeCredentialRegistration( + mockRegistrationResult, + ) verify { mockRegistrationResult wasNot Called mockActivity wasNot Called @@ -132,10 +135,10 @@ class CredentialProviderCompletionManagerTest { @Suppress("MaxLineLength") @Test - fun `completeFido2Registration should set CreateCredentialResponse, set activity result, then finish activity when result is Success`() { + fun `completeCredentialRegistration should set CreateCredentialResponse, set activity result, then finish activity when result is SuccessFido2`() { credentialProviderCompletionManager - .completeFido2Registration( - RegisterFido2CredentialResult.Success( + .completeCredentialRegistration( + CreateCredentialResult.Success.Fido2CredentialRegistered( responseJson = "registrationResponse", ), ) @@ -147,9 +150,24 @@ class CredentialProviderCompletionManagerTest { @Suppress("MaxLineLength") @Test - fun `completeFido2Registration should set CreateCredentialException, set activity result, then finish activity when result is Error`() { + fun `completeCredentialRegistration should set CreateCredentialResponse, set activity result, then finish activity when result is SuccessPassword`() { + credentialProviderCompletionManager.completeCredentialRegistration( + CreateCredentialResult.Success.PasswordCreated, + ) + + verifyActivityResultIsSetAndFinishedAfter { + PendingIntentHandler.setCreateCredentialResponse( + intent = any(), + response = any(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `completeCredentialRegistration should set CreateCredentialException, set activity result, then finish activity when result is Error`() { credentialProviderCompletionManager - .completeFido2Registration(RegisterFido2CredentialResult.Error("".asText())) + .completeCredentialRegistration(CreateCredentialResult.Error("".asText())) verifyActivityResultIsSetAndFinishedAfter { mockActivity.resources @@ -159,9 +177,9 @@ class CredentialProviderCompletionManagerTest { @Suppress("MaxLineLength") @Test - fun `completeFido2Registration should set CreateCredentialException, set activity result, then finish activity when result is Cancelled`() { + fun `completeCredentialRegistration should set CreateCredentialException, set activity result, then finish activity when result is Cancelled`() { credentialProviderCompletionManager - .completeFido2Registration(RegisterFido2CredentialResult.Cancelled) + .completeCredentialRegistration(CreateCredentialResult.Cancelled) verifyActivityResultIsSetAndFinishedAfter { PendingIntentHandler.setCreateCredentialException(any(), any()) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt index 5dca1e5e2c..d857759722 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt @@ -313,6 +313,31 @@ class RootNavScreenTest : BitwardenComposeTest() { } } + // Make sure navigating to vault unlocked for create password request works as expected: + rootNavStateFlow.value = RootNavState.VaultUnlockedForCreatePasswordRequest( + username = "activeUserId", + password = "mockPassword", + uri = "mockUri", + ) + composeTestRule.runOnIdle { + verify { + mockNavHostController.navigate( + route = VaultUnlockedGraphRoute, + navOptions = expectedNavOptions, + ) + mockNavHostController.navigate( + route = VaultAddEditRoute( + vaultAddEditMode = VaultAddEditMode.ADD, + vaultItemId = null, + vaultItemCipherType = VaultItemCipherType.LOGIN, + selectedFolderId = null, + selectedCollectionId = null, + ), + navOptions = expectedNavOptions, + ) + } + } + // Make sure navigating to vault unlocked for CreateCredentialRequest works as expected: rootNavStateFlow.value = RootNavState.VaultUnlockedForFido2Save( activeUserId = "activeUserId", diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt index 4939b174d5..598c782a3c 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -1,6 +1,9 @@ package com.x8bit.bitwarden.ui.platform.feature.rootnav import androidx.core.os.bundleOf +import androidx.credentials.CreatePasswordRequest +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.provider.ProviderCreateCredentialRequest import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager import com.bitwarden.cxf.model.ImportCredentialsRequestData import com.bitwarden.data.repository.model.Environment @@ -28,7 +31,9 @@ import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType import io.mockk.every import io.mockk.mockk +import io.mockk.mockkObject import io.mockk.mockkStatic +import io.mockk.unmockkObject import io.mockk.unmockkStatic import kotlinx.coroutines.flow.MutableStateFlow import org.junit.jupiter.api.AfterEach @@ -64,6 +69,7 @@ class RootNavViewModelTest : BaseViewModelTest() { @AfterEach fun tearDown() { unmockkStatic(::parseJwtTokenDataOrNull) + unmockkObject(ProviderCreateCredentialRequest.Companion) } @Test @@ -682,11 +688,18 @@ class RootNavViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test fun `when the active user has an unlocked vault but there is a Fido2Save special circumstance the nav state should be VaultUnlockedForFido2Save`() { + mockkObject(ProviderCreateCredentialRequest.Companion) + val createCredentialRequest = CreateCredentialRequest( userId = "activeUserId", isUserPreVerified = false, requestData = bundleOf(), ) + + every { ProviderCreateCredentialRequest.fromBundle(any()) } returns mockk { + every { callingRequest } returns mockk() + } + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.ProviderCreateCredential(createCredentialRequest) mutableUserStateFlow.tryEmit( @@ -728,6 +741,73 @@ class RootNavViewModelTest : BaseViewModelTest() { ) } + @Suppress("MaxLineLength") + @Test + fun `when the active user has an unlocked vault but there is a ProviderCreateCredential with password request the nav state should be VaultUnlockedForCreatePasswordRequest`() { + mockkObject(ProviderCreateCredentialRequest.Companion) + + val mockUsername = "testUser" + val mockPassword = "testPassword123" + val mockPackageName = "com.example.app" + + val createCredentialRequest = CreateCredentialRequest( + userId = "activeUserId", + isUserPreVerified = false, + requestData = bundleOf(), + ) + + every { ProviderCreateCredentialRequest.fromBundle(any()) } returns mockk { + every { callingRequest } returns mockk { + every { id } returns mockUsername + every { password } returns mockPassword + } + every { callingAppInfo } returns mockk { + every { packageName } returns mockPackageName + } + } + + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.ProviderCreateCredential(createCredentialRequest) + mutableUserStateFlow.tryEmit( + UserState( + activeUserId = "activeUserId", + accounts = listOf( + UserState.Account( + userId = "activeUserId", + name = "name", + email = "email", + avatarColorHex = "avatarHexColor", + environment = Environment.Us, + isPremium = true, + isLoggedIn = true, + isVaultUnlocked = true, + needsPasswordReset = false, + isBiometricsEnabled = false, + organizations = emptyList(), + needsMasterPassword = false, + trustedDevice = null, + hasMasterPassword = true, + isUsingKeyConnector = false, + onboardingStatus = OnboardingStatus.COMPLETE, + firstTimeState = FirstTimeState( + showImportLoginsCard = true, + ), + isExportable = true, + ), + ), + ), + ) + val viewModel = createViewModel() + assertEquals( + RootNavState.VaultUnlockedForCreatePasswordRequest( + username = mockUsername, + password = mockPassword, + uri = "androidapp://$mockPackageName", + ), + viewModel.stateFlow.value, + ) + } + @Suppress("MaxLineLength") @Test fun `when the active user has an unlocked vault but there is a Fido2Assertion special circumstance the nav state should be VaultUnlockedForFido2Assertion`() { diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt index 60c0cf8abf..eabfb9f8ef 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt @@ -57,7 +57,7 @@ import com.bitwarden.vault.UriMatchType import com.x8bit.bitwarden.data.util.advanceTimeByAndRunCurrent import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.ui.credentials.manager.CredentialProviderCompletionManager -import com.x8bit.bitwarden.ui.credentials.manager.model.RegisterFido2CredentialResult +import com.x8bit.bitwarden.ui.credentials.manager.model.CreateCredentialResult import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager import com.x8bit.bitwarden.ui.platform.manager.permissions.FakePermissionManager @@ -112,7 +112,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { every { launchUri(any()) } just runs } private val credentialProviderCompletionManager: CredentialProviderCompletionManager = mockk { - every { completeFido2Registration(any()) } just runs + every { completeCredentialRegistration(any()) } just runs } private val biometricsManager: BiometricsManager = mockk { every { isUserVerificationSupported } returns true @@ -233,18 +233,18 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { } @Test - fun `on CompleteFido2Create event should invoke Fido2CompletionManager`() { - val result = RegisterFido2CredentialResult.Success( + fun `on CompleteCredentialCreate event should invoke CredentialProviderCompletionManager`() { + val result = CreateCredentialResult.Success.Fido2CredentialRegistered( responseJson = "mockRegistrationResponse", ) - mutableEventFlow.tryEmit(VaultAddEditEvent.CompleteFido2Registration(result = result)) - verify { credentialProviderCompletionManager.completeFido2Registration(result) } + mutableEventFlow.tryEmit(VaultAddEditEvent.CompleteCredentialRegistration(result = result)) + verify { credentialProviderCompletionManager.completeCredentialRegistration(result) } } @Test - fun `Fido2Error dialog should display based on state`() { + fun `CredentialError dialog should display based on state`() { mutableStateFlow.value = DEFAULT_STATE_LOGIN.copy( - dialog = VaultAddEditState.DialogState.Fido2Error("mockMessage".asText()), + dialog = VaultAddEditState.DialogState.CredentialError("mockMessage".asText()), ) composeTestRule @@ -464,7 +464,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { @Test fun `clicking dismiss dialog on Fido2Error dialog should send Fido2ErrorDialogDismissed action`() { mutableStateFlow.value = DEFAULT_STATE_LOGIN.copy( - dialog = VaultAddEditState.DialogState.Fido2Error("mockMessage".asText()), + dialog = VaultAddEditState.DialogState.CredentialError("mockMessage".asText()), ) composeTestRule @@ -474,7 +474,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { verify { viewModel.trySendAction( - VaultAddEditAction.Common.Fido2ErrorDialogDismissed("mockMessage".asText()), + VaultAddEditAction.Common.CredentialErrorDialogDismissed("mockMessage".asText()), ) } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt index 36c0c538bb..7cf51127af 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt @@ -1,7 +1,6 @@ package com.x8bit.bitwarden.ui.vault.feature.addedit import androidx.core.os.bundleOf -import androidx.credentials.CreatePasswordRequest import androidx.credentials.CreatePublicKeyCredentialRequest import androidx.credentials.provider.CallingAppInfo import androidx.credentials.provider.ProviderCreateCredentialRequest @@ -78,7 +77,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.VaultData -import com.x8bit.bitwarden.ui.credentials.manager.model.RegisterFido2CredentialResult +import com.x8bit.bitwarden.ui.credentials.manager.model.CreateCredentialResult import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode @@ -1085,8 +1084,8 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { assertEquals(stateWithSavingDialog, stateFlow.awaitItem()) assertEquals(stateWithName, stateFlow.awaitItem()) assertEquals( - VaultAddEditEvent.CompleteFido2Registration( - RegisterFido2CredentialResult.Success( + VaultAddEditEvent.CompleteCredentialRegistration( + CreateCredentialResult.Success.Fido2CredentialRegistered( responseJson = "mockResponse", ), ), @@ -1183,55 +1182,6 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { } } - @Suppress("MaxLineLength") - @Test - fun `in add mode during fido2, SaveClick should show fido2 error dialog when request type is not supported`() = - runTest { - val fido2CredentialRequest = createMockCreateCredentialRequest(number = 1) - specialCircumstanceManager.specialCircumstance = - SpecialCircumstance.ProviderCreateCredential( - createCredentialRequest = fido2CredentialRequest, - ) - val stateWithName = createVaultAddItemState( - commonContentViewState = createCommonContentViewState( - name = "mockName-1", - ), - createCredentialRequest = fido2CredentialRequest, - ) - .copy(shouldExitOnSave = true) - - val mockProviderCreateCredentialRequest: ProviderCreateCredentialRequest = - mockk(relaxed = true) { - every { callingAppInfo } returns mockk(relaxed = true) - every { callingRequest } returns mockk(relaxed = true) - } - - every { - ProviderCreateCredentialRequest.fromBundle(any()) - } returns mockProviderCreateCredentialRequest - - mutableVaultDataFlow.value = DataState.Loaded( - createVaultData(), - ) - val viewModel = createAddVaultItemViewModel( - createSavedStateHandleWithState( - state = stateWithName, - vaultAddEditType = VaultAddEditType.AddItem, - vaultItemCipherType = VaultItemCipherType.LOGIN, - ), - ) - - viewModel.trySendAction(VaultAddEditAction.Common.SaveClick) - - assertEquals( - VaultAddEditState.DialogState.Fido2Error( - message = BitwardenString.passkey_operation_failed_because_the_request_is_unsupported - .asText(), - ), - viewModel.stateFlow.value.dialog, - ) - } - @Suppress("MaxLineLength") @Test fun `in add mode during fido2, SaveClick should emit fido user verification as optional when verification is PREFERRED`() = @@ -2276,7 +2226,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { fun `DismissFido2ErrorDialogClick should clear the dialog state then complete FIDO 2 create`() = runTest { val errorState = createVaultAddItemState( - dialogState = VaultAddEditState.DialogState.Fido2Error( + dialogState = VaultAddEditState.DialogState.CredentialError( message = BitwardenString.passkey_operation_failed_because_user_could_not_be_verified.asText(), ), ) @@ -2288,15 +2238,15 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { ), ) viewModel.trySendAction( - VaultAddEditAction.Common.Fido2ErrorDialogDismissed( + VaultAddEditAction.Common.CredentialErrorDialogDismissed( BitwardenString.passkey_operation_failed_because_user_could_not_be_verified.asText(), ), ) viewModel.eventFlow.test { assertNull(viewModel.stateFlow.value.dialog) assertEquals( - VaultAddEditEvent.CompleteFido2Registration( - result = RegisterFido2CredentialResult.Error( + VaultAddEditEvent.CompleteCredentialRegistration( + result = CreateCredentialResult.Error( BitwardenString.passkey_operation_failed_because_user_could_not_be_verified .asText(), ), @@ -4034,12 +3984,12 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `UserVerificationLockout should set isUserVerified to false and display Fido2ErrorDialog`() { + fun `UserVerificationLockout should set isUserVerified to false and display CredentialErrorDialog`() { viewModel.trySendAction(VaultAddEditAction.Common.UserVerificationLockOut) verify { bitwardenCredentialManager.isUserVerified = false } assertEquals( - VaultAddEditState.DialogState.Fido2Error( + VaultAddEditState.DialogState.CredentialError( message = BitwardenString.passkey_operation_failed_because_user_could_not_be_verified.asText(), ), viewModel.stateFlow.value.dialog, @@ -4048,7 +3998,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `UserVerificationCancelled should clear dialog state, set isUserVerified to false, and emit CompleteFido2Create with cancelled result`() = + fun `UserVerificationCancelled should clear dialog state, set isUserVerified to false, and emit CompleteCredentialRegistration with cancelled result`() = runTest { viewModel.trySendAction(VaultAddEditAction.Common.UserVerificationCancelled) @@ -4056,8 +4006,8 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { assertNull(viewModel.stateFlow.value.dialog) viewModel.eventFlow.test { assertEquals( - VaultAddEditEvent.CompleteFido2Registration( - result = RegisterFido2CredentialResult.Cancelled, + VaultAddEditEvent.CompleteCredentialRegistration( + result = CreateCredentialResult.Cancelled, ), awaitItem(), ) @@ -4066,12 +4016,12 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `UserVerificationFail should set isUserVerified to false, and display Fido2ErrorDialog`() { + fun `UserVerificationFail should set isUserVerified to false, and display CredentialErrorDialog`() { viewModel.trySendAction(VaultAddEditAction.Common.UserVerificationFail) verify { bitwardenCredentialManager.isUserVerified = false } assertEquals( - VaultAddEditState.DialogState.Fido2Error( + VaultAddEditState.DialogState.CredentialError( message = BitwardenString.passkey_operation_failed_because_user_could_not_be_verified.asText(), ), viewModel.stateFlow.value.dialog, @@ -4080,12 +4030,12 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `UserVerificationNotSupported should display Fido2ErrorDialog when active account not found`() { + fun `UserVerificationNotSupported should display CredentialErrorDialog when active account not found`() { mutableUserStateFlow.value = null viewModel.trySendAction(VaultAddEditAction.Common.UserVerificationNotSupported) verify { bitwardenCredentialManager.isUserVerified = false } assertEquals( - VaultAddEditState.DialogState.Fido2Error( + VaultAddEditState.DialogState.CredentialError( message = BitwardenString.passkey_operation_failed_because_user_could_not_be_verified.asText(), ), viewModel.stateFlow.value.dialog, @@ -4181,7 +4131,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `MasterPasswordFido2VerificationSubmit should display Fido2Error when password verification fails`() { + fun `MasterPasswordFido2VerificationSubmit should display CredentialError when password verification fails`() { val password = "password" coEvery { authRepository.validatePassword(password = password) @@ -4194,7 +4144,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { ) assertEquals( - VaultAddEditState.DialogState.Fido2Error( + VaultAddEditState.DialogState.CredentialError( message = BitwardenString.passkey_operation_failed_because_user_could_not_be_verified .asText(), ), @@ -4230,7 +4180,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `MasterPasswordFido2VerificationSubmit should display Fido2Error when user has no retries remaining`() { + fun `MasterPasswordFido2VerificationSubmit should display CredentialError when user has no retries remaining`() { val password = "password" every { bitwardenCredentialManager.hasAuthenticationAttemptsRemaining() } returns false coEvery { @@ -4244,7 +4194,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { ) assertEquals( - VaultAddEditState.DialogState.Fido2Error( + VaultAddEditState.DialogState.CredentialError( message = BitwardenString.passkey_operation_failed_because_user_could_not_be_verified .asText(), ), @@ -4286,7 +4236,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `PinFido2VerificationSubmit should display Fido2Error when Pin verification fails`() { + fun `PinFido2VerificationSubmit should display CredentialError when Pin verification fails`() { val pin = "PIN" coEvery { authRepository.validatePin(pin = pin) @@ -4299,7 +4249,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { ) assertEquals( - VaultAddEditState.DialogState.Fido2Error( + VaultAddEditState.DialogState.CredentialError( message = BitwardenString.passkey_operation_failed_because_user_could_not_be_verified .asText(), ), @@ -4335,7 +4285,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `PinFido2VerificationSubmit should display Fido2Error when user has no retries remaining`() { + fun `PinFido2VerificationSubmit should display CredentialError when user has no retries remaining`() { val pin = "PIN" every { bitwardenCredentialManager.hasAuthenticationAttemptsRemaining() } returns false coEvery { @@ -4349,7 +4299,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { ) assertEquals( - VaultAddEditState.DialogState.Fido2Error( + VaultAddEditState.DialogState.CredentialError( message = BitwardenString.passkey_operation_failed_because_user_could_not_be_verified .asText(), ), @@ -4430,13 +4380,13 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { } @Test - fun `DismissFido2VerificationDialogClick should display Fido2ErrorDialog`() { + fun `DismissFido2VerificationDialogClick should display CredentialErrorDialog`() { viewModel.trySendAction( VaultAddEditAction.Common.DismissFido2VerificationDialogClick, ) assertEquals( - VaultAddEditState.DialogState.Fido2Error( + VaultAddEditState.DialogState.CredentialError( message = BitwardenString .passkey_operation_failed_because_user_could_not_be_verified .asText(), @@ -4447,7 +4397,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `UserVerificationSuccess should display Fido2ErrorDialog when request is invalid`() { + fun `UserVerificationSuccess should display CredentialErrorDialog when request is invalid`() { every { authRepository.activeUserId } returns null specialCircumstanceManager.specialCircumstance = SpecialCircumstance.ProviderCreateCredential( @@ -4459,7 +4409,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { viewModel.trySendAction(VaultAddEditAction.Common.UserVerificationSuccess) assertEquals( - VaultAddEditState.DialogState.Fido2Error( + VaultAddEditState.DialogState.CredentialError( message = BitwardenString.passkey_operation_failed_because_the_request_is_unsupported .asText(), ), @@ -4499,7 +4449,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `Fido2RegisterCredentialResult Error should show toast and emit CompleteFido2Registration result`() = + fun `Fido2RegisterCredentialResult Error should show toast and emit CompleteCredentialRegistration result`() = runTest { val mockRequest = createMockCreateCredentialRequest(number = 1) val mockResult = Fido2RegisterCredentialResult.Error.InternalError @@ -4527,8 +4477,8 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { viewModel.eventFlow.test { assertEquals( - VaultAddEditEvent.CompleteFido2Registration( - RegisterFido2CredentialResult.Error( + VaultAddEditEvent.CompleteCredentialRegistration( + CreateCredentialResult.Error( BitwardenString.passkey_registration_failed_due_to_an_internal_error .asText(), ), @@ -4543,7 +4493,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `Fido2RegisterCredentialResult Success should show toast and emit CompleteFido2Registration result`() = + fun `Fido2RegisterCredentialResult Success should show toast and emit CompleteCredentialRegistration result`() = runTest { val mockRequest = createMockCreateCredentialRequest(number = 1) val mockResult = Fido2RegisterCredentialResult.Success( @@ -4572,8 +4522,8 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { viewModel.eventFlow.test { assertEquals( - VaultAddEditEvent.CompleteFido2Registration( - RegisterFido2CredentialResult.Success( + VaultAddEditEvent.CompleteCredentialRegistration( + CreateCredentialResult.Success.Fido2CredentialRegistered( responseJson = "mockResponse", ), ), diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt index d3e5c7da50..845175645d 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt @@ -49,9 +49,9 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockLoginView import com.x8bit.bitwarden.ui.credentials.manager.CredentialProviderCompletionManager import com.x8bit.bitwarden.ui.credentials.manager.model.AssertFido2CredentialResult +import com.x8bit.bitwarden.ui.credentials.manager.model.CreateCredentialResult import com.x8bit.bitwarden.ui.credentials.manager.model.GetCredentialsResult import com.x8bit.bitwarden.ui.credentials.manager.model.GetPasswordCredentialResult -import com.x8bit.bitwarden.ui.credentials.manager.model.RegisterFido2CredentialResult import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager @@ -106,7 +106,7 @@ class VaultItemListingScreenTest : BitwardenComposeTest() { every { launchUri(any()) } just runs } private val credentialProviderCompletionManager: CredentialProviderCompletionManager = mockk { - every { completeFido2Registration(any()) } just runs + every { completeCredentialRegistration(any()) } just runs every { completeFido2Assertion(any()) } just runs every { completePasswordGet(any()) } just runs every { completeProviderGetCredentialsRequest(any()) } just runs @@ -1966,11 +1966,11 @@ class VaultItemListingScreenTest : BitwardenComposeTest() { @Suppress("MaxLineLength") @Test - fun `CompleteFido2Registration event should call CredentialProviderCompletionManager with result`() { - val result = RegisterFido2CredentialResult.Success("mockResponse") - mutableEventFlow.tryEmit(VaultItemListingEvent.CompleteFido2Registration(result)) + fun `CompleteCredentialRegistration event should call CredentialProviderCompletionManager with result`() { + val result = CreateCredentialResult.Success.Fido2CredentialRegistered("mockResponse") + mutableEventFlow.tryEmit(VaultItemListingEvent.CompleteCredentialRegistration(result)) verify { - credentialProviderCompletionManager.completeFido2Registration(result) + credentialProviderCompletionManager.completeCredentialRegistration(result) } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index 08866ad887..cf30dcb113 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -99,9 +99,9 @@ import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.ui.credentials.manager.model.AssertFido2CredentialResult +import com.x8bit.bitwarden.ui.credentials.manager.model.CreateCredentialResult import com.x8bit.bitwarden.ui.credentials.manager.model.GetCredentialsResult import com.x8bit.bitwarden.ui.credentials.manager.model.GetPasswordCredentialResult -import com.x8bit.bitwarden.ui.credentials.manager.model.RegisterFido2CredentialResult import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType @@ -261,10 +261,18 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { private val mockGetPublicKeyCredentialOption = mockk { every { requestJson } returns "mockRequestJson" } + private val mockCreatePublicKeyCredentialOption = mockk { + every { requestJson } returns "mockRequestJson" + every { origin } returns "mockOrigin" + } private val mockProviderGetCredentialRequest = mockk { every { credentialOptions } returns listOf(mockGetPublicKeyCredentialOption) every { callingAppInfo } returns mockCallingAppInfo } + private val mockProviderCreateCredentialRequest = mockk { + every { callingRequest } returns mockCreatePublicKeyCredentialOption + every { callingAppInfo } returns mockCallingAppInfo + } private val mockBeginGetPublicKeyCredentialOption = mockk { every { requestJson } returns "mockRequestJson" } @@ -272,14 +280,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { every { beginGetCredentialOptions } returns listOf(mockBeginGetPublicKeyCredentialOption) every { callingAppInfo } returns mockCallingAppInfo } - private val mockCreatePublicKeyCredentialRequest = mockk { - every { requestJson } returns "mockRequestJson" - every { origin } returns "mockOrigin" - } - private val mockProviderCreateCredentialRequest = mockk { - every { callingRequest } returns mockCreatePublicKeyCredentialRequest - every { callingAppInfo } returns mockCallingAppInfo - } + private val mutableSnackbarDataFlow: MutableSharedFlow = bufferedMutableSharedFlow() private val snackbarRelayManager: SnackbarRelayManager = mockk { @@ -303,12 +304,13 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ProviderGetCredentialRequest.Companion, BeginGetCredentialRequest.Companion, ) - every { - ProviderCreateCredentialRequest.fromBundle(any()) - } returns mockProviderCreateCredentialRequest + every { ProviderGetCredentialRequest.fromBundle(any()) } returns mockProviderGetCredentialRequest + every { + ProviderCreateCredentialRequest.fromBundle(any()) + } returns mockProviderCreateCredentialRequest every { BeginGetCredentialRequest.fromBundle(any()) } returns mockBeginGetCredentialRequest @@ -348,6 +350,8 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { coEvery { originManager.validateOrigin(any(), any()) } returns ValidateOriginResult.Success(null) + + setupFido2CreateRequest() val viewModel = createVaultItemListingViewModel() viewModel.stateFlow.test { @@ -511,6 +515,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { shouldFinishWhenComplete = false, ) val searchType = SearchType.Vault.All + setupFido2CreateRequest() val viewModel = createVaultItemListingViewModel() viewModel.eventFlow.test { viewModel.trySendAction(VaultItemListingsAction.SearchIconClick) @@ -677,7 +682,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `ItemClick for vault item during FIDO 2 registration should show FIDO 2 error dialog when cipherView is null`() { + fun `ItemClick for vault item during credential registration should show credential error dialog when cipherView is null`() { val cipherView = createMockCipherView(number = 1) specialCircumstanceManager.specialCircumstance = SpecialCircumstance.ProviderCreateCredential( @@ -755,6 +760,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } returns Fido2RegisterCredentialResult.Success("mockResponse") + setupFido2CreateRequest() val viewModel = createVaultItemListingViewModel() viewModel.trySendAction( VaultItemListingsAction.ItemClick( @@ -810,6 +816,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } returns Fido2RegisterCredentialResult.Success("mockResponse") + setupFido2CreateRequest() val viewModel = createVaultItemListingViewModel() viewModel.trySendAction( VaultItemListingsAction.ItemClick( @@ -881,6 +888,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } returns Fido2RegisterCredentialResult.Success("mockResponse") + setupFido2CreateRequest() val viewModel = createVaultItemListingViewModel() viewModel.trySendAction( VaultItemListingsAction.ItemClick( @@ -934,6 +942,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } returns Fido2RegisterCredentialResult.Success("mockResponse") + setupFido2CreateRequest() val viewModel = createVaultItemListingViewModel() viewModel.trySendAction( VaultItemListingsAction.ItemClick( @@ -2313,6 +2322,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ), ) + setupFido2CreateRequest() val viewModel = createVaultItemListingViewModel() mutableVaultDataStateFlow.value = dataState @@ -2990,6 +3000,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Test fun `icon loading state updates should update isIconLoadingDisabled`() = runTest { + setupFido2CreateRequest() val viewModel = createVaultItemListingViewModel() assertFalse(viewModel.stateFlow.value.isIconLoadingDisabled) @@ -3065,6 +3076,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } returns ValidateOriginResult.Success("mockOrigin") + setupFido2CreateRequest() createVaultItemListingViewModel() coVerify(ordering = Ordering.ORDERED) { @@ -3075,9 +3087,10 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Test fun `ValidateOriginResult should update dialog state on Unknown error`() = runTest { + val mockCredentialsRequest = createMockCreateCredentialRequest(number = 1) specialCircumstanceManager.specialCircumstance = SpecialCircumstance.ProviderCreateCredential( - createMockCreateCredentialRequest(number = 1), + mockCredentialsRequest, ) coEvery { originManager.validateOrigin( @@ -3086,6 +3099,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } returns ValidateOriginResult.Error.Unknown + setupFido2CreateRequest() val viewModel = createVaultItemListingViewModel() assertEquals( @@ -3111,6 +3125,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } returns ValidateOriginResult.Error.PrivilegedAppNotAllowed + setupFido2CreateRequest() val viewModel = createVaultItemListingViewModel() assertEquals( @@ -3139,6 +3154,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } returns ValidateOriginResult.Error.PrivilegedAppSignatureNotFound + setupFido2CreateRequest() val viewModel = createVaultItemListingViewModel() assertEquals( @@ -3165,6 +3181,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } returns ValidateOriginResult.Error.PasskeyNotSupportedForApp + setupFido2CreateRequest() val viewModel = createVaultItemListingViewModel() assertEquals( @@ -3191,6 +3208,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } returns ValidateOriginResult.Error.AssetLinkNotFound + setupFido2CreateRequest() val viewModel = createVaultItemListingViewModel() assertEquals( @@ -3204,7 +3222,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `Fido2RegisterCredentialResult Error should show toast and emit CompleteFido2Registration result`() = + fun `Fido2RegisterCredentialResult Error should show toast and emit CompleteCredentialRegistration result`() = runTest { val mockResult = Fido2RegisterCredentialResult.Error.InternalError @@ -3217,8 +3235,8 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { viewModel.eventFlow.test { assertEquals( - VaultItemListingEvent.CompleteFido2Registration( - RegisterFido2CredentialResult.Error( + VaultItemListingEvent.CompleteCredentialRegistration( + CreateCredentialResult.Error( BitwardenString.passkey_registration_failed_due_to_an_internal_error.asText(), ), ), @@ -3232,7 +3250,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `Fido2RegisterCredentialResult Success should show toast and emit CompleteFido2Registration result`() = + fun `Fido2RegisterCredentialResult Success should show toast and emit CompleteCredentialRegistration result`() = runTest { val mockResult = Fido2RegisterCredentialResult.Success( responseJson = "mockResponse", @@ -3247,8 +3265,8 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { viewModel.eventFlow.test { assertEquals( - VaultItemListingEvent.CompleteFido2Registration( - RegisterFido2CredentialResult.Success( + VaultItemListingEvent.CompleteCredentialRegistration( + CreateCredentialResult.Success.Fido2CredentialRegistered( responseJson = "mockResponse", ), ), @@ -3268,6 +3286,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { SpecialCircumstance.ProviderCreateCredential( createMockCreateCredentialRequest(number = 1), ) + setupFido2CreateRequest() val viewModel = createVaultItemListingViewModel() viewModel.trySendAction( VaultItemListingsAction.DismissCredentialManagerErrorDialogClick( @@ -3277,8 +3296,8 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { viewModel.eventFlow.test { assertNull(viewModel.stateFlow.value.dialogState) assertEquals( - VaultItemListingEvent.CompleteFido2Registration( - result = RegisterFido2CredentialResult.Error( + VaultItemListingEvent.CompleteCredentialRegistration( + result = CreateCredentialResult.Error( "".asText(), ), ), @@ -4130,12 +4149,13 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `UserVerificationCancelled should clear dialog state, set isUserVerified to false, and emit CompleteFido2Registration with cancelled result`() = + fun `UserVerificationCancelled should clear dialog state, set isUserVerified to false, and emit CompleteCredentialRegistration with cancelled result`() = runTest { specialCircumstanceManager.specialCircumstance = SpecialCircumstance.ProviderCreateCredential( createMockCreateCredentialRequest(number = 1), ) + setupFido2CreateRequest() val viewModel = createVaultItemListingViewModel() viewModel.trySendAction(VaultItemListingsAction.UserVerificationCancelled) @@ -4143,8 +4163,8 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { assertNull(viewModel.stateFlow.value.dialogState) viewModel.eventFlow.test { assertEquals( - VaultItemListingEvent.CompleteFido2Registration( - result = RegisterFido2CredentialResult.Cancelled, + VaultItemListingEvent.CompleteCredentialRegistration( + result = CreateCredentialResult.Cancelled, ), awaitItem(), ) @@ -4277,6 +4297,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ), ) + setupFido2CreateRequest() val viewModel = createVaultItemListingViewModel() viewModel.trySendAction( VaultItemListingsAction.UserVerificationSuccess( @@ -4314,6 +4335,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { responseJson = "mockResponse", ) + setupFido2CreateRequest() val viewModel = createVaultItemListingViewModel() viewModel.trySendAction( VaultItemListingsAction.UserVerificationSuccess( @@ -5013,6 +5035,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } returns UserVerificationRequirement.REQUIRED + setupFido2CreateRequest() val viewModel = createVaultItemListingViewModel() viewModel.trySendAction( VaultItemListingsAction.ConfirmOverwriteExistingPasskeyClick( @@ -5339,6 +5362,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Test fun `InternetConnectionErrorReceived should show network error if no internet connection`() = runTest { + setupFido2CreateRequest() val viewModel = createVaultItemListingViewModel() viewModel.trySendAction( VaultItemListingsAction.Internal.InternetConnectionErrorReceived, @@ -5372,6 +5396,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { originManager.validateOrigin(any(), any()) } returns ValidateOriginResult.Error.PrivilegedAppNotAllowed + setupFido2CreateRequest() val viewModel = createVaultItemListingViewModel() viewModel.trySendAction( @@ -5403,6 +5428,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { mockCallingAppInfo.getSignatureFingerprintAsHexString() } returns null + setupFido2CreateRequest() val viewModel = createVaultItemListingViewModel() viewModel.trySendAction( @@ -5791,6 +5817,24 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } } + private fun setupFido2CreateRequest( + mockCallingAppInfo: CallingAppInfo = this.mockCallingAppInfo, + mockCreatePublicKeyCredentialRequest: CreatePublicKeyCredentialRequest = + mockk { + every { requestJson } returns "mockRequestJson" + every { origin } returns "mockOrigin" + }, + mockProviderCreateCredentialRequest: ProviderCreateCredentialRequest = + mockk { + every { callingAppInfo } returns mockCallingAppInfo + every { callingRequest } returns mockCreatePublicKeyCredentialRequest + }, + ) { + every { + ProviderCreateCredentialRequest.fromBundle(any()) + } returns mockProviderCreateCredentialRequest + } + private fun createSavedStateHandleWithVaultItemListingType( vaultItemListingType: VaultItemListingType, ): SavedStateHandle = SavedStateHandle().apply { diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 94b0f28c9a..9c69ab2e60 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -691,6 +691,7 @@ Do you want to switch to this account? Follow the steps from Duo to finish logging in. Launch Duo Your passkey will be saved to your Bitwarden vault for %1$s + Your password will be saved to your Bitwarden vault for %1$s Passkeys not supported for this app Passkey operation failed because browser is not privileged Passkey operation failed because browser signature does not match @@ -917,6 +918,8 @@ Do you want to switch to this account? Passkey operation failed because the request is unsupported. Password operation failed because the selected item does not exist. Password operation failed because no item was selected. + Credential operation failed because the request is invalid. + Credential operation failed because the request is unsupported. Credential operation failed because user verification attempts exceeded. Credential operation failed because user is locked out. Credential operation failed because the selected item does not exist.