diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt index 70d622f807c..a410cec8ab2 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt @@ -56,6 +56,8 @@ import com.x8bit.bitwarden.ui.vault.feature.attachments.attachmentDestination import com.x8bit.bitwarden.ui.vault.feature.attachments.navigateToAttachment import com.x8bit.bitwarden.ui.vault.feature.attachments.preview.navigateToPreviewAttachment import com.x8bit.bitwarden.ui.vault.feature.attachments.preview.previewAttachmentDestination +import com.x8bit.bitwarden.ui.vault.feature.cardscanner.cardScanDestination +import com.x8bit.bitwarden.ui.vault.feature.cardscanner.navigateToCardScanScreen import com.x8bit.bitwarden.ui.vault.feature.importlogins.importLoginsScreenDestination import com.x8bit.bitwarden.ui.vault.feature.importlogins.navigateToImportLoginsScreen import com.x8bit.bitwarden.ui.vault.feature.item.navigateToVaultItem @@ -172,6 +174,9 @@ fun NavGraphBuilder.vaultUnlockedGraph( onNavigateToQrCodeScanScreen = { navController.navigateToQrCodeScanScreen() }, + onNavigateToCardScanScreen = { + navController.navigateToCardScanScreen() + }, onNavigateToManualCodeEntryScreen = { navController.navigateToManualCodeEntryScreen() }, @@ -205,6 +210,9 @@ fun NavGraphBuilder.vaultUnlockedGraph( }, onNavigateToPreviewAttachment = { navController.navigateToPreviewAttachment(it) }, ) + cardScanDestination( + onNavigateBack = { navController.popBackStack() }, + ) vaultQrCodeScanDestination( onNavigateToManualCodeEntryScreen = { navController.popBackStack() diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditCardItems.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditCardItems.kt index 33d1b2b6554..8c97a4f81a1 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditCardItems.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditCardItems.kt @@ -10,17 +10,22 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.bitwarden.ui.platform.base.util.standardHorizontalMargin +import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton import com.bitwarden.ui.platform.components.dropdown.BitwardenMultiSelectButton import com.bitwarden.ui.platform.components.field.BitwardenPasswordField import com.bitwarden.ui.platform.components.field.BitwardenTextField import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText import com.bitwarden.ui.platform.components.model.CardStyle +import com.bitwarden.ui.platform.components.util.rememberVectorPainter +import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditCardTypeHandlers import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand @@ -34,6 +39,9 @@ import kotlinx.collections.immutable.toImmutableList @Suppress("LongMethod") fun LazyListScope.vaultAddEditCardItems( cardState: VaultAddEditState.ViewState.Content.ItemType.Card, + isCardScannerEnabled: Boolean, + cardHolderNameFocusRequester: FocusRequester, + onScanCardClick: () -> Unit, cardHandlers: VaultAddEditCardTypeHandlers, ) { item { @@ -47,6 +55,21 @@ fun LazyListScope.vaultAddEditCardItems( ) } + if (isCardScannerEnabled) { + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenOutlinedButton( + label = stringResource(id = BitwardenString.scan_card), + onClick = onScanCardClick, + icon = rememberVectorPainter(id = BitwardenDrawable.ic_camera_small), + modifier = Modifier + .testTag("ScanCardButton") + .fillMaxWidth() + .standardHorizontalMargin(), + ) + } + } + item { Spacer(modifier = Modifier.height(8.dp)) BitwardenTextField( @@ -57,7 +80,8 @@ fun LazyListScope.vaultAddEditCardItems( cardStyle = CardStyle.Top(), modifier = Modifier .fillMaxWidth() - .standardHorizontalMargin(), + .standardHorizontalMargin() + .focusRequester(cardHolderNameFocusRequester), ) } item { diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditItemContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditItemContent.kt index 8a841548511..5775f8bf9f2 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditItemContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditItemContent.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -51,6 +52,8 @@ fun CoachMarkScope.VaultAddEditContent( identityItemTypeHandlers: VaultAddEditIdentityTypeHandlers, cardItemTypeHandlers: VaultAddEditCardTypeHandlers, sshKeyItemTypeHandlers: VaultAddEditSshKeyTypeHandlers, + isCardScannerEnabled: Boolean, + cardHolderNameFocusRequester: FocusRequester, modifier: Modifier = Modifier, lazyListState: LazyListState, permissionsManager: PermissionsManager, @@ -64,7 +67,10 @@ fun CoachMarkScope.VaultAddEditContent( onResult = { isGranted -> when (state.type) { is VaultAddEditState.ViewState.Content.ItemType.SecureNotes -> Unit - is VaultAddEditState.ViewState.Content.ItemType.Card -> Unit + is VaultAddEditState.ViewState.Content.ItemType.Card -> { + cardItemTypeHandlers.onScanCardClick(isGranted) + } + is VaultAddEditState.ViewState.Content.ItemType.Identity -> Unit is VaultAddEditState.ViewState.Content.ItemType.SshKey -> Unit is VaultAddEditState.ViewState.Content.ItemType.Login -> { @@ -236,6 +242,15 @@ fun CoachMarkScope.VaultAddEditContent( is VaultAddEditState.ViewState.Content.ItemType.Card -> { vaultAddEditCardItems( cardState = state.type, + isCardScannerEnabled = isCardScannerEnabled, + cardHolderNameFocusRequester = cardHolderNameFocusRequester, + onScanCardClick = { + if (permissionsManager.checkPermission(Manifest.permission.CAMERA)) { + cardItemTypeHandlers.onScanCardClick(true) + } else { + launcher.launch(Manifest.permission.CAMERA) + } + }, cardHandlers = cardItemTypeHandlers, ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditNavigation.kt index 5c6e1867856..540bbae3c14 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditNavigation.kt @@ -73,6 +73,7 @@ fun NavGraphBuilder.vaultAddEditDestination( onNavigateBack: () -> Unit, onNavigateToManualCodeEntryScreen: () -> Unit, onNavigateToQrCodeScanScreen: () -> Unit, + onNavigateToCardScanScreen: () -> Unit, onNavigateToGeneratorModal: (GeneratorMode.Modal) -> Unit, onNavigateToAttachments: (cipherId: String) -> Unit, onNavigateToMoveToOrganization: (cipherId: String, showOnlyCollections: Boolean) -> Unit, @@ -82,6 +83,7 @@ fun NavGraphBuilder.vaultAddEditDestination( onNavigateBack = onNavigateBack, onNavigateToManualCodeEntryScreen = onNavigateToManualCodeEntryScreen, onNavigateToQrCodeScanScreen = onNavigateToQrCodeScanScreen, + onNavigateToCardScanScreen = onNavigateToCardScanScreen, onNavigateToGeneratorModal = onNavigateToGeneratorModal, onNavigateToAttachments = onNavigateToAttachments, onNavigateToMoveToOrganization = onNavigateToMoveToOrganization, 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 44f42ed4445..4a2044a0ed4 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 @@ -27,6 +27,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -65,6 +66,7 @@ import com.bitwarden.ui.platform.composition.LocalExitManager import com.bitwarden.ui.platform.composition.LocalIntentManager import com.bitwarden.ui.platform.manager.IntentManager import com.bitwarden.ui.platform.manager.exit.ExitManager +import com.bitwarden.ui.platform.manager.util.startAppSettingsActivity import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.platform.theme.BitwardenTheme @@ -99,6 +101,7 @@ import kotlinx.coroutines.launch fun VaultAddEditScreen( onNavigateBack: () -> Unit, onNavigateToQrCodeScanScreen: () -> Unit, + onNavigateToCardScanScreen: () -> Unit, viewModel: VaultAddEditViewModel = hiltViewModel(), permissionsManager: PermissionsManager = LocalPermissionsManager.current, intentManager: IntentManager = LocalIntentManager.current, @@ -121,6 +124,7 @@ fun VaultAddEditScreen( lazyListState = lazyListState, orderedList = AddEditItemCoachMark.entries, ) + val cardHolderNameFocusRequester = remember { FocusRequester() } val scope = rememberCoroutineScope() val snackbarHostState = rememberBitwardenSnackbarHostState() EventsEffect(viewModel = viewModel) { event -> @@ -129,6 +133,10 @@ fun VaultAddEditScreen( onNavigateToQrCodeScanScreen() } + is VaultAddEditEvent.NavigateToCardScan -> { + onNavigateToCardScanScreen() + } + is VaultAddEditEvent.NavigateToManualCodeEntry -> { onNavigateToManualCodeEntryScreen() } @@ -194,6 +202,14 @@ fun VaultAddEditScreen( is VaultAddEditEvent.NavigateToPremium -> { intentManager.launchUri(uri = event.uri.toUri()) } + + VaultAddEditEvent.FocusCardHolderName -> { + cardHolderNameFocusRequester.requestFocus() + } + + VaultAddEditEvent.NavigateToAppSettings -> { + intentManager.startAppSettingsActivity() + } } } @@ -267,6 +283,11 @@ fun VaultAddEditScreen( onUpgradeToPremiumClick = { viewModel.trySendAction(VaultAddEditAction.Common.UpgradeToPremiumClick) }, + onCameraPermissionSettingsClick = { + viewModel.trySendAction( + VaultAddEditAction.Common.CameraPermissionSettingsClick, + ) + }, ) if (pendingDeleteCipher) { @@ -393,6 +414,8 @@ fun VaultAddEditScreen( identityItemTypeHandlers = identityItemTypeHandlers, cardItemTypeHandlers = cardItemTypeHandlers, sshKeyItemTypeHandlers = sshKeyItemTypeHandlers, + isCardScannerEnabled = state.isCardScannerEnabled, + cardHolderNameFocusRequester = cardHolderNameFocusRequester, lazyListState = lazyListState, onPreviousCoachMark = { coroutineScope.launch { @@ -453,6 +476,7 @@ private fun VaultAddEditItemDialogs( onRetryPinSetUpFido2Verification: () -> Unit, onDismissFido2Verification: () -> Unit, onUpgradeToPremiumClick: () -> Unit, + onCameraPermissionSettingsClick: () -> Unit, ) { when (dialogState) { is VaultAddEditState.DialogState.ArchiveRequiresPremium -> { @@ -558,6 +582,20 @@ private fun VaultAddEditItemDialogs( ) } + is VaultAddEditState.DialogState.CameraPermissionDenied -> { + BitwardenTwoButtonDialog( + title = stringResource(BitwardenString.allow_camera_access), + message = stringResource( + id = BitwardenString.to_scan_your_card_we_need_access_to_your_camera, + ), + confirmButtonText = stringResource(id = BitwardenString.go_to_settings), + dismissButtonText = stringResource(id = BitwardenString.not_now), + onConfirmClick = onCameraPermissionSettingsClick, + onDismissClick = onDismissRequest, + onDismissRequest = onDismissRequest, + ) + } + null -> Unit } } 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 2b6ff58399d..65ec60f9bc5 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 @@ -13,7 +13,10 @@ import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault import com.bitwarden.network.model.PolicyTypeJson import com.bitwarden.ui.platform.base.BackgroundEvent import com.bitwarden.ui.platform.base.BaseViewModel +import com.bitwarden.ui.platform.base.DeferredBackgroundEvent import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData +import com.bitwarden.ui.platform.feature.cardscanner.manager.CardScanManager +import com.bitwarden.ui.platform.feature.cardscanner.util.CardScanResult import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager import com.bitwarden.ui.platform.model.TotpData import com.bitwarden.ui.platform.resource.BitwardenPlurals @@ -87,6 +90,7 @@ import com.x8bit.bitwarden.ui.vault.model.VaultCollection import com.x8bit.bitwarden.ui.vault.model.VaultIdentityTitle import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType +import com.x8bit.bitwarden.ui.vault.util.detectCardBrand import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -121,6 +125,7 @@ class VaultAddEditViewModel @Inject constructor( savedStateHandle: SavedStateHandle, featureFlagManager: FeatureFlagManager, generatorRepository: GeneratorRepository, + cardScanManager: CardScanManager, private val snackbarRelayManager: SnackbarRelayManager, private val toastManager: ToastManager, private val authRepository: AuthRepository, @@ -178,6 +183,7 @@ class VaultAddEditViewModel @Inject constructor( VaultAddEditState( isArchiveEnabled = featureFlagManager.getFeatureFlag(FlagKey.ArchiveItems), + isCardScannerEnabled = featureFlagManager.getFeatureFlag(FlagKey.CardScanner), vaultAddEditType = vaultAddEditType, cipherType = vaultCipherType, viewState = when (vaultAddEditType) { @@ -279,6 +285,18 @@ class VaultAddEditViewModel @Inject constructor( .onEach(::sendAction) .launchIn(viewModelScope) + featureFlagManager + .getFeatureFlagFlow(FlagKey.CardScanner) + .map { VaultAddEditAction.Internal.CardScannerFlagUpdateReceive(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) + + cardScanManager + .cardScanResultFlow + .map { VaultAddEditAction.Internal.CardScanResultReceive(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) + snackbarRelayManager .getSnackbarDataFlow(SnackbarRelay.CIPHER_MOVED_TO_ORGANIZATION) .map { VaultAddEditAction.Internal.SnackbarDataReceived(it) } @@ -325,6 +343,10 @@ class VaultAddEditViewModel @Inject constructor( is VaultAddEditAction.Common.ConfirmDeleteClick -> handleConfirmDeleteClick() is VaultAddEditAction.Common.CloseClick -> handleCloseClick() is VaultAddEditAction.Common.DismissDialog -> handleDismissDialog() + is VaultAddEditAction.Common.CameraPermissionSettingsClick -> { + handleCameraPermissionSettingsClick() + } + is VaultAddEditAction.Common.SaveClick -> handleSaveClick() is VaultAddEditAction.Common.AddNewCustomFieldClick -> { handleAddNewCustomFieldClick(action) @@ -672,6 +694,11 @@ class VaultAddEditViewModel @Inject constructor( clearDialogState() } + private fun handleCameraPermissionSettingsClick() { + clearDialogState() + sendEvent(VaultAddEditEvent.NavigateToAppSettings) + } + private fun handleInitialAutofillDialogDismissed() { settingsRepository.initialAutofillDialogShown = true clearDialogState() @@ -1542,6 +1569,27 @@ class VaultAddEditViewModel @Inject constructor( is VaultAddEditAction.ItemType.CardType.SecurityCodeVisibilityChange -> { handleSecurityCodeVisibilityChange(action) } + + is VaultAddEditAction.ItemType.CardType.ScanCardClick -> { + handleScanCardClick(action) + } + } + } + + private fun handleScanCardClick( + action: VaultAddEditAction.ItemType.CardType.ScanCardClick, + ) { + if (!state.isCardScannerEnabled) return + if (action.isGranted) { + sendEvent(VaultAddEditEvent.NavigateToCardScan) + } else { + mutableStateFlow.update { + it.copy( + dialog = VaultAddEditState + .DialogState + .CameraPermissionDenied, + ) + } } } @@ -1653,6 +1701,14 @@ class VaultAddEditViewModel @Inject constructor( handleArchiveItemsFlagUpdateReceive(action) } + is VaultAddEditAction.Internal.CardScannerFlagUpdateReceive -> { + handleCardScannerFlagUpdateReceive(action) + } + + is VaultAddEditAction.Internal.CardScanResultReceive -> { + handleCardScanResultReceive(action) + } + is VaultAddEditAction.Internal.DeleteCipherReceive -> handleDeleteCipherReceive(action) is VaultAddEditAction.Internal.TotpCodeReceive -> handleVaultTotpCodeReceive(action) is VaultAddEditAction.Internal.VaultDataReceive -> handleVaultDataReceive(action) @@ -1870,6 +1926,40 @@ class VaultAddEditViewModel @Inject constructor( mutableStateFlow.update { it.copy(isArchiveEnabled = action.isEnabled) } } + private fun handleCardScannerFlagUpdateReceive( + action: VaultAddEditAction.Internal.CardScannerFlagUpdateReceive, + ) { + mutableStateFlow.update { it.copy(isCardScannerEnabled = action.isEnabled) } + } + + private fun handleCardScanResultReceive( + action: VaultAddEditAction.Internal.CardScanResultReceive, + ) { + when (val result = action.cardScanResult) { + is CardScanResult.Success -> { + val data = result.cardScanData + updateCardContent { cardType -> + cardType.copy( + number = data.number ?: cardType.number, + expirationYear = data.expirationYear + ?: cardType.expirationYear, + expirationMonth = data.expirationMonth + ?.toExpirationMonth() + ?: cardType.expirationMonth, + securityCode = data.securityCode + ?: cardType.securityCode, + brand = data.number + ?.detectCardBrand() + ?: cardType.brand, + ) + } + sendEvent(VaultAddEditEvent.FocusCardHolderName) + } + + is CardScanResult.ScanError -> Unit + } + } + private fun handleDeleteCipherReceive(action: VaultAddEditAction.Internal.DeleteCipherReceive) { when (val result = action.result) { is DeleteCipherResult.Error -> { @@ -2404,6 +2494,24 @@ class VaultAddEditViewModel @Inject constructor( //endregion Utility Functions } +@Suppress("MagicNumber") +private fun String.toExpirationMonth(): VaultCardExpirationMonth = + when (this.toIntOrNull()) { + 1 -> VaultCardExpirationMonth.JANUARY + 2 -> VaultCardExpirationMonth.FEBRUARY + 3 -> VaultCardExpirationMonth.MARCH + 4 -> VaultCardExpirationMonth.APRIL + 5 -> VaultCardExpirationMonth.MAY + 6 -> VaultCardExpirationMonth.JUNE + 7 -> VaultCardExpirationMonth.JULY + 8 -> VaultCardExpirationMonth.AUGUST + 9 -> VaultCardExpirationMonth.SEPTEMBER + 10 -> VaultCardExpirationMonth.OCTOBER + 11 -> VaultCardExpirationMonth.NOVEMBER + 12 -> VaultCardExpirationMonth.DECEMBER + else -> VaultCardExpirationMonth.SELECT + } + /** * Represents the state for adding an item to the vault. * @@ -2428,6 +2536,7 @@ data class VaultAddEditState( val defaultUriMatchType: UriMatchType, private val shouldShowCoachMarkTour: Boolean, private val isArchiveEnabled: Boolean, + val isCardScannerEnabled: Boolean, ) : Parcelable { /** @@ -3021,6 +3130,13 @@ data class VaultAddEditState( */ @Parcelize data object Fido2PinSetUpError : DialogState() + + /** + * Displays a dialog informing the user that camera permission is required + * to use the card scanner, with an option to navigate to app settings. + */ + @Parcelize + data object CameraPermissionDenied : DialogState() } } @@ -3098,6 +3214,11 @@ sealed class VaultAddEditEvent { */ data object NavigateToQrCodeScan : VaultAddEditEvent() + /** + * Navigate to the card scan screen. + */ + data object NavigateToCardScan : VaultAddEditEvent() + /** * Navigate to the manual code entry screen. */ @@ -3142,6 +3263,16 @@ sealed class VaultAddEditEvent { * Navigate the user to the learn more help page */ data object NavigateToLearnMore : VaultAddEditEvent() + + /** + * Focus the cardholder name field after a successful card scan. + */ + data object FocusCardHolderName : VaultAddEditEvent(), DeferredBackgroundEvent + + /** + * Navigate to the app settings screen. + */ + data object NavigateToAppSettings : VaultAddEditEvent() } /** @@ -3169,6 +3300,11 @@ sealed class VaultAddEditAction { */ data object DismissDialog : Common() + /** + * The user has clicked the settings button in the camera permission dialog. + */ + data object CameraPermissionSettingsClick : Common() + /** * The user has clicked the attachments overflow option. */ @@ -3698,6 +3834,13 @@ sealed class VaultAddEditAction { * @property isVisible The new code visibility state. */ data class SecurityCodeVisibilityChange(val isVisible: Boolean) : CardType() + + /** + * Fired when the scan card button is clicked. + * + * @property isGranted Whether camera permission was granted. + */ + data class ScanCardClick(val isGranted: Boolean) : CardType() } /** @@ -3841,5 +3984,19 @@ sealed class VaultAddEditAction { data class ArchiveItemsFlagUpdateReceive( val isEnabled: Boolean, ) : Internal() + + /** + * Indicates that the Card Scanner flag has been updated. + */ + data class CardScannerFlagUpdateReceive( + val isEnabled: Boolean, + ) : Internal() + + /** + * Indicates that a card scan result has been received. + */ + data class CardScanResultReceive( + val cardScanResult: CardScanResult, + ) : Internal() } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/handlers/VaultAddEditCardTypeHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/handlers/VaultAddEditCardTypeHandlers.kt index 777211b48b6..0a7104d52de 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/handlers/VaultAddEditCardTypeHandlers.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/handlers/VaultAddEditCardTypeHandlers.kt @@ -9,7 +9,7 @@ import com.x8bit.bitwarden.ui.vault.model.VaultCardExpirationMonth * A collection of handler functions specifically tailored for managing actions * within the context of adding card items to a vault. * - * @property onCardHolderNameTextChange Handles the action when the card holder name text is changed. + * @property onCardHolderNameTextChange Handles the action when the cardholder name text is changed. * @property onNumberTextChange Handles the action when the number text is changed. * @property onBrandSelected Handles the action when a brand is selected. * @property onExpirationMonthSelected Handles the action when an expiration month is selected. @@ -18,8 +18,8 @@ import com.x8bit.bitwarden.ui.vault.model.VaultCardExpirationMonth * @property onSecurityCodeVisibilityChange Handles the action when the security code visibility * changes. * @property onNumberVisibilityChange Handles the action when the number visibility changes. + * @property onScanCardClick Handles the action when the scan card button is clicked. */ -@Suppress("MaxLineLength") data class VaultAddEditCardTypeHandlers( val onCardHolderNameTextChange: (String) -> Unit, val onNumberTextChange: (String) -> Unit, @@ -29,6 +29,7 @@ data class VaultAddEditCardTypeHandlers( val onSecurityCodeTextChange: (String) -> Unit, val onSecurityCodeVisibilityChange: (Boolean) -> Unit, val onNumberVisibilityChange: (Boolean) -> Unit, + val onScanCardClick: (Boolean) -> Unit, ) { @Suppress("UndocumentedPublicClass") companion object { @@ -37,6 +38,7 @@ data class VaultAddEditCardTypeHandlers( * Creates an instance of [VaultAddEditCardTypeHandlers] by binding actions * to the provided [VaultAddEditViewModel]. */ + @Suppress("LongMethod") fun create(viewModel: VaultAddEditViewModel): VaultAddEditCardTypeHandlers = VaultAddEditCardTypeHandlers( onCardHolderNameTextChange = { newCardHolderName -> @@ -93,6 +95,13 @@ data class VaultAddEditCardTypeHandlers( VaultAddEditAction.ItemType.CardType.NumberVisibilityChange(isVisible = it), ) }, + onScanCardClick = { isGranted -> + viewModel.trySendAction( + VaultAddEditAction.ItemType.CardType.ScanCardClick( + isGranted = isGranted, + ), + ) + }, ) } } 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 3b610fb859f..ad7a2e2a4f0 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 @@ -8,6 +8,7 @@ import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsOff @@ -31,6 +32,7 @@ import androidx.compose.ui.test.onChildren import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onLast import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo @@ -94,6 +96,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { private var onNavigateToManualCodeEntryScreenCalled = false private var onNavigateToGeneratorModalType: GeneratorMode.Modal? = null private var onNavigateToAttachmentsId: String? = null + private var onNavigateToCardScanScreenCalled = false private var onNavigateToMoveToOrganizationId: String? = null private val mutableEventFlow = bufferedMutableSharedFlow() @@ -136,6 +139,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { onNavigateToGeneratorModal = { onNavigateToGeneratorModalType = it }, onNavigateToAttachments = { onNavigateToAttachmentsId = it }, onNavigateToMoveToOrganization = { id, _ -> onNavigateToMoveToOrganizationId = id }, + onNavigateToCardScanScreen = { onNavigateToCardScanScreenCalled = true }, viewModel = viewModel, ) } @@ -188,6 +192,45 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { assertTrue(onNavigateQrCodeScanScreenCalled) } + @Test + fun `on NavigateToCardScan event should invoke onNavigateToCardScanScreen`() { + mutableEventFlow.tryEmit(VaultAddEditEvent.NavigateToCardScan) + assertTrue(onNavigateToCardScanScreenCalled) + } + + @Test + fun `on FocusCardHolderName event should focus field`() { + mutableStateFlow.value = DEFAULT_STATE_CARD + composeTestRule.waitForIdle() + mutableEventFlow.tryEmit(VaultAddEditEvent.FocusCardHolderName) + composeTestRule.waitForIdle() + composeTestRule + .onNodeWithTag("CardholderNameEntry") + .performScrollTo() + .assertIsFocused() + } + + @Test + fun `scan card button should be displayed when isCardScannerEnabled is true`() { + mutableStateFlow.value = DEFAULT_STATE_CARD.copy( + isCardScannerEnabled = true, + ) + composeTestRule + .onNodeWithText("Scan card") + .performScrollTo() + .assertIsDisplayed() + } + + @Test + fun `scan card button should not be displayed when isCardScannerEnabled is false`() { + mutableStateFlow.value = DEFAULT_STATE_CARD.copy( + isCardScannerEnabled = false, + ) + composeTestRule + .onNodeWithText("Scan card") + .assertDoesNotExist() + } + @Test fun `on NavigateToManualCodeEntry event should invoke NavigateToManualCodeEntry`() { mutableEventFlow.tryEmit(VaultAddEditEvent.NavigateToManualCodeEntry) @@ -4500,6 +4543,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { defaultUriMatchType = UriMatchTypeModel.EXACT, hasPremium = false, isArchiveEnabled = true, + isCardScannerEnabled = false, ) private val DEFAULT_STATE_LOGIN = VaultAddEditState( @@ -4516,6 +4560,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { defaultUriMatchType = UriMatchTypeModel.EXACT, hasPremium = false, isArchiveEnabled = true, + isCardScannerEnabled = false, ) private val DEFAULT_STATE_IDENTITY = VaultAddEditState( @@ -4532,6 +4577,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { defaultUriMatchType = UriMatchTypeModel.EXACT, hasPremium = false, isArchiveEnabled = true, + isCardScannerEnabled = false, ) private val DEFAULT_STATE_CARD = VaultAddEditState( @@ -4548,6 +4594,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { defaultUriMatchType = UriMatchTypeModel.EXACT, hasPremium = false, isArchiveEnabled = true, + isCardScannerEnabled = false, ) private val DEFAULT_STATE_SECURE_NOTES_CUSTOM_FIELDS = VaultAddEditState( @@ -4574,6 +4621,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { defaultUriMatchType = UriMatchTypeModel.EXACT, hasPremium = false, isArchiveEnabled = true, + isCardScannerEnabled = false, ) private val DEFAULT_STATE_SECURE_NOTES = VaultAddEditState( @@ -4590,6 +4638,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { defaultUriMatchType = UriMatchTypeModel.EXACT, hasPremium = false, isArchiveEnabled = true, + isCardScannerEnabled = false, ) private val DEFAULT_STATE_SSH_KEYS = VaultAddEditState( @@ -4606,6 +4655,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { defaultUriMatchType = UriMatchTypeModel.EXACT, hasPremium = false, isArchiveEnabled = true, + isCardScannerEnabled = false, ) private val ALTERED_COLLECTIONS = listOf( 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 886460e8ab2..d4c428005ba 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 @@ -18,6 +18,9 @@ import com.bitwarden.network.model.createMockPolicy import com.bitwarden.send.SendView import com.bitwarden.ui.platform.base.BaseViewModelTest import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData +import com.bitwarden.ui.platform.feature.cardscanner.manager.CardScanManager +import com.bitwarden.ui.platform.feature.cardscanner.util.CardScanData +import com.bitwarden.ui.platform.feature.cardscanner.util.CardScanResult import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager import com.bitwarden.ui.platform.model.TotpData import com.bitwarden.ui.platform.resource.BitwardenPlurals @@ -224,9 +227,17 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { } private val environmentRepository = FakeEnvironmentRepository() private val mutableArchiveItemsFlow = MutableStateFlow(true) + private val mutableCardScannerFlow = MutableStateFlow(false) + private val mutableCardScanResultFlow = + bufferedMutableSharedFlow() + private val cardScanManager: CardScanManager = mockk { + every { cardScanResultFlow } returns mutableCardScanResultFlow + } private val featureFlagManager: FeatureFlagManager = mockk { every { getFeatureFlag(FlagKey.ArchiveItems) } answers { mutableArchiveItemsFlow.value } every { getFeatureFlagFlow(FlagKey.ArchiveItems) } returns mutableArchiveItemsFlow + every { getFeatureFlag(FlagKey.CardScanner) } answers { mutableCardScannerFlow.value } + every { getFeatureFlagFlow(FlagKey.CardScanner) } returns mutableCardScannerFlow } @BeforeEach @@ -269,6 +280,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { defaultUriMatchType = UriMatchTypeModel.EXACT, hasPremium = true, isArchiveEnabled = true, + isCardScannerEnabled = false, ) val viewModel = createAddVaultItemViewModel( savedStateHandle = createSavedStateHandleWithState( @@ -358,6 +370,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { defaultUriMatchType = UriMatchTypeModel.EXACT, hasPremium = true, isArchiveEnabled = true, + isCardScannerEnabled = false, ), viewModel.stateFlow.value, ) @@ -5117,6 +5130,277 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { } } + @Test + fun `CardScannerFlagUpdateReceive should update isCardScannerEnabled`() = + runTest { + val initState = createVaultAddItemState() + val viewModel = createAddVaultItemViewModel() + mutableCardScannerFlow.value = true + assertEquals( + initState.copy(isCardScannerEnabled = true), + viewModel.stateFlow.value, + ) + } + + @Test + fun `CardScanResultReceive with Success should update card fields and focus name`() = + runTest { + val viewModel = createAddVaultItemViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = createVaultAddItemState( + vaultItemCipherType = VaultItemCipherType.CARD, + typeContentViewState = VaultAddEditState + .ViewState + .Content + .ItemType + .Card(), + ), + vaultAddEditType = VaultAddEditType.AddItem, + vaultItemCipherType = VaultItemCipherType.CARD, + ), + ) + viewModel.eventFlow.test { + mutableCardScanResultFlow.tryEmit( + CardScanResult.Success( + cardScanData = CardScanData( + number = "4111111111111111", + expirationMonth = "12", + expirationYear = "2025", + securityCode = "123", + ), + ), + ) + val expectedCard = VaultAddEditState + .ViewState + .Content + .ItemType + .Card( + number = "4111111111111111", + expirationYear = "2025", + expirationMonth = VaultCardExpirationMonth.DECEMBER, + securityCode = "123", + brand = VaultCardBrand.VISA, + ) + val content = viewModel.stateFlow.value.viewState + as VaultAddEditState.ViewState.Content + assertEquals(expectedCard, content.type) + assertEquals( + VaultAddEditEvent.FocusCardHolderName, + awaitItem(), + ) + } + } + + @Test + fun `CardScanResultReceive with ScanError should not change state or focus`() = + runTest { + val initialCardState = VaultAddEditState + .ViewState + .Content + .ItemType + .Card() + val viewModel = createAddVaultItemViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = createVaultAddItemState( + vaultItemCipherType = VaultItemCipherType.CARD, + typeContentViewState = initialCardState, + ), + vaultAddEditType = VaultAddEditType.AddItem, + vaultItemCipherType = VaultItemCipherType.CARD, + ), + ) + viewModel.eventFlow.test { + mutableCardScanResultFlow.tryEmit(CardScanResult.ScanError()) + val content = viewModel.stateFlow.value.viewState + as VaultAddEditState.ViewState.Content + assertEquals(initialCardState, content.type) + expectNoEvents() + } + } + + @Test + fun `ScanCardClick with permission granted should send NavigateToCardScan`() = + runTest { + mutableCardScannerFlow.value = true + val viewModel = createAddVaultItemViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = createVaultAddItemState( + vaultItemCipherType = VaultItemCipherType.CARD, + typeContentViewState = VaultAddEditState + .ViewState + .Content + .ItemType + .Card(), + ), + vaultAddEditType = VaultAddEditType.AddItem, + vaultItemCipherType = VaultItemCipherType.CARD, + ), + ) + viewModel.eventFlow.test { + viewModel.trySendAction( + VaultAddEditAction.ItemType.CardType.ScanCardClick( + isGranted = true, + ), + ) + assertEquals( + VaultAddEditEvent.NavigateToCardScan, + awaitItem(), + ) + } + } + + @Test + fun `ScanCardClick when flag disabled should not navigate`() = + runTest { + mutableCardScannerFlow.value = false + val viewModel = createAddVaultItemViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = createVaultAddItemState( + vaultItemCipherType = VaultItemCipherType.CARD, + typeContentViewState = VaultAddEditState + .ViewState + .Content + .ItemType + .Card(), + ), + vaultAddEditType = VaultAddEditType.AddItem, + vaultItemCipherType = VaultItemCipherType.CARD, + ), + ) + viewModel.eventFlow.test { + viewModel.trySendAction( + VaultAddEditAction.ItemType.CardType.ScanCardClick( + isGranted = true, + ), + ) + expectNoEvents() + } + } + + @Test + fun `ScanCardClick with permission denied should show camera permission dialog`() = + runTest { + mutableCardScannerFlow.value = true + val initialState = createVaultAddItemState( + vaultItemCipherType = VaultItemCipherType.CARD, + typeContentViewState = VaultAddEditState + .ViewState + .Content + .ItemType + .Card(), + ) + val viewModel = createAddVaultItemViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = initialState, + vaultAddEditType = VaultAddEditType.AddItem, + vaultItemCipherType = VaultItemCipherType.CARD, + ), + ) + viewModel.trySendAction( + VaultAddEditAction.ItemType.CardType.ScanCardClick( + isGranted = false, + ), + ) + assertEquals( + initialState.copy( + dialog = VaultAddEditState + .DialogState + .CameraPermissionDenied, + isCardScannerEnabled = true, + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `CameraPermissionSettingsClick should clear dialog and navigate to app settings`() = + runTest { + mutableCardScannerFlow.value = true + val initialState = createVaultAddItemState( + vaultItemCipherType = VaultItemCipherType.CARD, + typeContentViewState = VaultAddEditState + .ViewState + .Content + .ItemType + .Card(), + dialogState = VaultAddEditState + .DialogState + .CameraPermissionDenied, + ) + val viewModel = createAddVaultItemViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = initialState, + vaultAddEditType = VaultAddEditType.AddItem, + vaultItemCipherType = VaultItemCipherType.CARD, + ), + ) + viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow -> + stateFlow.skipItems(1) + viewModel.trySendAction( + VaultAddEditAction.Common.CameraPermissionSettingsClick, + ) + assertEquals( + initialState.copy( + dialog = null, + isCardScannerEnabled = true, + ), + stateFlow.awaitItem(), + ) + assertEquals( + VaultAddEditEvent.NavigateToAppSettings, + eventFlow.awaitItem(), + ) + } + } + + @Test + fun `CardScanResultReceive with partial scan should preserve existing fields`() = + runTest { + val initialCard = VaultAddEditState + .ViewState + .Content + .ItemType + .Card( + cardHolderName = "EXISTING NAME", + expirationMonth = VaultCardExpirationMonth.JUNE, + expirationYear = "2030", + securityCode = "999", + ) + val viewModel = createAddVaultItemViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = createVaultAddItemState( + vaultItemCipherType = VaultItemCipherType.CARD, + typeContentViewState = initialCard, + ), + vaultAddEditType = VaultAddEditType.AddItem, + vaultItemCipherType = VaultItemCipherType.CARD, + ), + ) + viewModel.eventFlow.test { + mutableCardScanResultFlow.tryEmit( + CardScanResult.Success( + cardScanData = CardScanData( + number = "4111111111111111", + expirationMonth = null, + expirationYear = null, + securityCode = null, + ), + ), + ) + val expectedCard = initialCard.copy( + number = "4111111111111111", + brand = VaultCardBrand.VISA, + ) + val content = viewModel.stateFlow.value.viewState + as VaultAddEditState.ViewState.Content + assertEquals(expectedCard, content.type) + assertEquals( + VaultAddEditEvent.FocusCardHolderName, + awaitItem(), + ) + } + } + //region Helper functions @Suppress("LongParameterList") @@ -5154,6 +5438,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { defaultUriMatchType = UriMatchTypeModel.EXACT, hasPremium = hasPremium, isArchiveEnabled = true, + isCardScannerEnabled = false, ) @Suppress("LongParameterList") @@ -5239,6 +5524,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { featureFlagManager = featureFlagManager, authRepository = authRepository, clipboardManager = bitwardenClipboardManager, + cardScanManager = cardScanManager, policyManager = policyManager, vaultRepository = vaultRepo, bitwardenCredentialManager = bitwardenCredentialManager, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanViewModelTest.kt index a4d6e5c6ee0..c1cac8c78ab 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanViewModelTest.kt @@ -88,15 +88,27 @@ class CardScanViewModelTest : BaseViewModelTest() { } verify(exactly = 1) { cardScanManager.emitCardScanResult(any()) } + assertEquals( + DEFAULT_STATE.copy(hasHandledScan = true), + viewModel.stateFlow.value, + ) } - private fun createViewModel(): CardScanViewModel = + private fun createViewModel( + initialState: CardScanState? = null, + ): CardScanViewModel = CardScanViewModel( - savedStateHandle = SavedStateHandle(), + savedStateHandle = SavedStateHandle().apply { + set("state", initialState) + }, cardScanManager = cardScanManager, ) } +private val DEFAULT_STATE = CardScanState( + hasHandledScan = false, +) + private val CARD_SCAN_DATA = CardScanData( number = "4111111111111111", expirationMonth = "12", diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 5a132e90c5b..fc71580284d 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1253,4 +1253,6 @@ Do you want to switch to this account? Payment not received yet Return to Stripe in your browser to finish your upgrade. Go back + Allow camera access + To scan your card, we’ll need access to your camera. You can change this anytime in your device settings.