diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt index e2804dfee39..31ef577c1e7 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt @@ -10,6 +10,8 @@ import com.bitwarden.network.service.CiphersService import com.bitwarden.network.service.FolderService import com.bitwarden.network.service.SendsService import com.bitwarden.network.service.SyncService +import com.bitwarden.ui.platform.feature.cardscanner.manager.CardScanManager +import com.bitwarden.ui.platform.feature.cardscanner.manager.CardScanManagerImpl import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource import com.x8bit.bitwarden.data.auth.manager.KdfManager @@ -60,6 +62,10 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object VaultManagerModule { + @Provides + @Singleton + fun provideCardScanManager(): CardScanManager = CardScanManagerImpl() + @Provides @Singleton fun provideVaultMigrationManager( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt index ed7affadd0d..8df5b79302a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt @@ -22,9 +22,14 @@ import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeImporter import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeRequestValidator import com.bitwarden.cxf.validator.CredentialExchangeRequestValidator import com.bitwarden.cxf.validator.dsl.credentialExchangeRequestValidator +import com.bitwarden.ui.platform.composition.LocalCardTextAnalyzer import com.bitwarden.ui.platform.composition.LocalExitManager import com.bitwarden.ui.platform.composition.LocalIntentManager import com.bitwarden.ui.platform.composition.LocalQrCodeAnalyzer +import com.bitwarden.ui.platform.feature.cardscanner.util.CardDataParser +import com.bitwarden.ui.platform.feature.cardscanner.util.CardDataParserImpl +import com.bitwarden.ui.platform.feature.cardscanner.util.CardTextAnalyzer +import com.bitwarden.ui.platform.feature.cardscanner.util.CardTextAnalyzerImpl import com.bitwarden.ui.platform.feature.qrcodescan.util.QrCodeAnalyzer import com.bitwarden.ui.platform.feature.qrcodescan.util.QrCodeAnalyzerImpl import com.bitwarden.ui.platform.manager.IntentManager @@ -84,6 +89,10 @@ fun LocalManagerProvider( credentialExchangeRequestValidator: CredentialExchangeRequestValidator = credentialExchangeRequestValidator(activity = activity), authTabLaunchers: AuthTabLaunchers, + cardDataParser: CardDataParser = CardDataParserImpl(), + cardTextAnalyzer: CardTextAnalyzer = CardTextAnalyzerImpl( + cardDataParser = cardDataParser, + ), qrCodeAnalyzer: QrCodeAnalyzer = QrCodeAnalyzerImpl(), content: @Composable () -> Unit, ) { @@ -103,6 +112,7 @@ fun LocalManagerProvider( LocalCredentialExchangeCompletionManager provides credentialExchangeCompletionManager, LocalCredentialExchangeRequestValidator provides credentialExchangeRequestValidator, LocalAuthTabLaunchers provides authTabLaunchers, + LocalCardTextAnalyzer provides cardTextAnalyzer, LocalQrCodeAnalyzer provides qrCodeAnalyzer, content = content, ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanNavigation.kt new file mode 100644 index 00000000000..29b96446a9a --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanNavigation.kt @@ -0,0 +1,39 @@ +@file:OmitFromCoverage + +package com.x8bit.bitwarden.ui.vault.feature.cardscanner + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import com.bitwarden.annotation.OmitFromCoverage +import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions +import kotlinx.serialization.Serializable + +/** + * The type-safe route for the card scan screen. + */ +@OmitFromCoverage +@Serializable +data object CardScanRoute + +/** + * Add the card scan screen to the nav graph. + */ +fun NavGraphBuilder.cardScanDestination( + onNavigateBack: () -> Unit, +) { + composableWithSlideTransitions { + CardScanScreen( + onNavigateBack = onNavigateBack, + ) + } +} + +/** + * Navigate to the card scan screen. + */ +fun NavController.navigateToCardScanScreen( + navOptions: NavOptions? = null, +) { + this.navigate(route = CardScanRoute, navOptions = navOptions) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanScreen.kt new file mode 100644 index 00000000000..d72a15cc108 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanScreen.kt @@ -0,0 +1,183 @@ +package com.x8bit.bitwarden.ui.vault.feature.cardscanner + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.bitwarden.ui.platform.base.util.EventsEffect +import com.bitwarden.ui.platform.base.util.StatusBarsAppearanceAffect +import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar +import com.bitwarden.ui.platform.components.camera.CameraPreview +import com.bitwarden.ui.platform.components.camera.CardScanOverlay +import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.bitwarden.ui.platform.components.util.rememberVectorPainter +import com.bitwarden.ui.platform.composition.LocalCardTextAnalyzer +import com.bitwarden.ui.platform.feature.cardscanner.util.CardTextAnalyzer +import com.bitwarden.ui.platform.model.WindowSize +import com.bitwarden.ui.platform.resource.BitwardenDrawable +import com.bitwarden.ui.platform.resource.BitwardenString +import com.bitwarden.ui.platform.theme.BitwardenTheme +import com.bitwarden.ui.platform.theme.LocalBitwardenColorScheme +import com.bitwarden.ui.platform.theme.color.darkBitwardenColorScheme +import com.bitwarden.ui.platform.util.rememberWindowSize + +/** + * The screen to scan credit cards for the application. + */ +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CardScanScreen( + onNavigateBack: () -> Unit, + viewModel: CardScanViewModel = hiltViewModel(), + cardTextAnalyzer: CardTextAnalyzer = LocalCardTextAnalyzer.current, +) { + cardTextAnalyzer.onCardScanned = { cardScanData -> + viewModel.trySendAction( + CardScanAction.CardScanReceive(cardScanData = cardScanData), + ) + } + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + is CardScanEvent.NavigateBack -> onNavigateBack() + } + } + + // This screen should always look like it's in dark mode + CompositionLocalProvider( + LocalBitwardenColorScheme provides darkBitwardenColorScheme, + ) { + StatusBarsAppearanceAffect() + BitwardenScaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + BitwardenTopAppBar( + title = stringResource(id = BitwardenString.scan_card), + navigationIcon = rememberVectorPainter( + id = BitwardenDrawable.ic_close, + ), + navigationIconContentDescription = stringResource( + id = BitwardenString.close, + ), + onNavigationIconClick = { + viewModel.trySendAction(CardScanAction.CloseClick) + }, + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior( + state = rememberTopAppBarState(), + ), + ) + }, + ) { + CameraPreview( + cameraErrorReceive = { + viewModel.trySendAction( + CardScanAction.CameraSetupErrorReceive, + ) + }, + analyzer = cardTextAnalyzer, + modifier = Modifier.fillMaxSize(), + ) + when (rememberWindowSize()) { + WindowSize.Compact -> { + CardScanContentCompact() + } + + WindowSize.Medium -> { + CardScanContentMedium() + } + } + } + } +} + +@Composable +private fun CardScanContentCompact( + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + CardScanOverlay( + overlayWidth = 300.dp, + modifier = Modifier.weight(2f), + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceAround, + modifier = Modifier + .weight(1f) + .fillMaxSize() + .background(color = BitwardenTheme.colorScheme.background.scrim) + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = stringResource( + id = BitwardenString.scan_card_instruction, + ), + textAlign = TextAlign.Center, + color = BitwardenTheme.colorScheme.text.primary, + style = BitwardenTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.navigationBarsPadding()) + } + } +} + +@Composable +private fun CardScanContentMedium( + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + CardScanOverlay( + overlayWidth = 250.dp, + modifier = Modifier.weight(2f), + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceAround, + modifier = Modifier + .weight(1f) + .fillMaxSize() + .background(color = BitwardenTheme.colorScheme.background.scrim) + .padding(horizontal = 16.dp) + .navigationBarsPadding() + .verticalScroll(rememberScrollState()), + ) { + Text( + text = stringResource( + id = BitwardenString.scan_card_instruction, + ), + textAlign = TextAlign.Center, + color = BitwardenTheme.colorScheme.text.primary, + style = BitwardenTheme.typography.bodySmall, + ) + } + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanViewModel.kt new file mode 100644 index 00000000000..1bbcd7a02c0 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanViewModel.kt @@ -0,0 +1,97 @@ +package com.x8bit.bitwarden.ui.vault.feature.cardscanner + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import com.bitwarden.ui.platform.base.BaseViewModel +import com.bitwarden.ui.platform.base.DeferredBackgroundEvent +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 dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +private const val KEY_STATE = "state" + +/** + * Handles [CardScanAction] and launches [CardScanEvent] for the [CardScanScreen]. + */ +@HiltViewModel +class CardScanViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val cardScanManager: CardScanManager, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] + ?: CardScanState(hasHandledScan = false), +) { + + override fun handleAction(action: CardScanAction) { + when (action) { + is CardScanAction.CloseClick -> handleCloseClick() + is CardScanAction.CameraSetupErrorReceive -> handleCameraErrorReceive() + is CardScanAction.CardScanReceive -> handleCardScanReceive(action) + } + } + + private fun handleCloseClick() { + sendEvent(CardScanEvent.NavigateBack) + } + + private fun handleCameraErrorReceive() { + cardScanManager.emitCardScanResult(CardScanResult.ScanError()) + sendEvent(CardScanEvent.NavigateBack) + } + + private fun handleCardScanReceive(action: CardScanAction.CardScanReceive) { + if (state.hasHandledScan) return + mutableStateFlow.update { it.copy(hasHandledScan = true) } + cardScanManager.emitCardScanResult( + CardScanResult.Success(cardScanData = action.cardScanData), + ) + sendEvent(CardScanEvent.NavigateBack) + } +} + +/** + * Models events for the [CardScanScreen]. + */ +sealed class CardScanEvent { + + /** + * Navigate back. Added [DeferredBackgroundEvent] as scan might fire before + * events are consumed. + */ + data object NavigateBack : CardScanEvent(), DeferredBackgroundEvent +} + +/** + * Models actions for the [CardScanScreen]. + */ +sealed class CardScanAction { + + /** + * User clicked close. + */ + data object CloseClick : CardScanAction() + + /** + * A card has been scanned with the detected fields. + */ + data class CardScanReceive( + val cardScanData: CardScanData, + ) : CardScanAction() + + /** + * The camera is unable to be set up. + */ + data object CameraSetupErrorReceive : CardScanAction() +} + +/** + * Represents the state of the card scan screen. + */ +@Parcelize +data class CardScanState( + val hasHandledScan: Boolean, +) : Parcelable diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt index 03eaa7824b0..81eb7c44000 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt @@ -5,6 +5,7 @@ import com.bitwarden.cxf.importer.CredentialExchangeImporter import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager import com.bitwarden.cxf.validator.CredentialExchangeRequestValidator import com.bitwarden.ui.platform.base.BaseComposeTest +import com.bitwarden.ui.platform.feature.cardscanner.util.CardTextAnalyzer import com.bitwarden.ui.platform.feature.qrcodescan.util.QrCodeAnalyzer import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import com.bitwarden.ui.platform.manager.IntentManager @@ -49,6 +50,7 @@ abstract class BitwardenComposeTest : BaseComposeTest() { credentialExchangeImporter: CredentialExchangeImporter = mockk(), credentialExchangeCompletionManager: CredentialExchangeCompletionManager = mockk(), credentialExchangeRequestValidator: CredentialExchangeRequestValidator = mockk(), + cardTextAnalyzer: CardTextAnalyzer = mockk(), qrCodeAnalyzer: QrCodeAnalyzer = mockk(), test: @Composable () -> Unit, ) { @@ -69,6 +71,7 @@ abstract class BitwardenComposeTest : BaseComposeTest() { credentialExchangeImporter = credentialExchangeImporter, credentialExchangeCompletionManager = credentialExchangeCompletionManager, credentialExchangeRequestValidator = credentialExchangeRequestValidator, + cardTextAnalyzer = cardTextAnalyzer, qrCodeAnalyzer = qrCodeAnalyzer, ) { BitwardenTheme( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanScreenTest.kt new file mode 100644 index 00000000000..df9a95d9409 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanScreenTest.kt @@ -0,0 +1,80 @@ +package com.x8bit.bitwarden.ui.vault.feature.cardscanner + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow +import com.bitwarden.ui.platform.feature.cardscanner.util.FakeCardTextAnalyzer +import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import junit.framework.TestCase.assertTrue +import org.junit.Before +import org.junit.Test +import org.robolectric.annotation.Config + +class CardScanScreenTest : BitwardenComposeTest() { + + private var onNavigateBackCalled = false + + private val cardTextAnalyzer = FakeCardTextAnalyzer() + + private val mutableEventFlow = bufferedMutableSharedFlow() + + private val viewModel = mockk(relaxed = true) { + every { eventFlow } returns mutableEventFlow + } + + @Before + fun setup() { + setContent( + cardTextAnalyzer = cardTextAnalyzer, + ) { + CardScanScreen( + onNavigateBack = { onNavigateBackCalled = true }, + viewModel = viewModel, + ) + } + } + + @Test + fun `screen should render with close button`() { + composeTestRule + .onNodeWithContentDescription("Close") + .assertIsDisplayed() + } + + @Test + fun `close button click should send CloseClick action`() { + composeTestRule + .onNodeWithContentDescription("Close") + .performClick() + + verify { + viewModel.trySendAction(CardScanAction.CloseClick) + } + } + + @Test + fun `on NavigateBack event should invoke onNavigateBack`() { + mutableEventFlow.tryEmit(CardScanEvent.NavigateBack) + assertTrue(onNavigateBackCalled) + } + + @Test + fun `title should display Scan card`() { + composeTestRule + .onNodeWithText("Scan card") + .assertExists() + } + + @Config(qualifiers = "land") + @Test + fun `instruction text should display in landscape mode`() { + composeTestRule + .onNodeWithText("Position your card within the frame to scan it.") + .assertIsDisplayed() + } +} 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 new file mode 100644 index 00000000000..a4d6e5c6ee0 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanViewModelTest.kt @@ -0,0 +1,105 @@ +package com.x8bit.bitwarden.ui.vault.feature.cardscanner + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.bitwarden.ui.platform.base.BaseViewModelTest +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 io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class CardScanViewModelTest : BaseViewModelTest() { + + private val cardScanManager: CardScanManager = mockk { + every { emitCardScanResult(any()) } just runs + } + + @Test + fun `CloseClick should emit NavigateBack`() = runTest { + val viewModel = createViewModel() + + viewModel.eventFlow.test { + viewModel.trySendAction(CardScanAction.CloseClick) + assertEquals(CardScanEvent.NavigateBack, awaitItem()) + } + } + + @Test + fun `CameraSetupErrorReceive should emit ScanError and NavigateBack`() = runTest { + val viewModel = createViewModel() + + viewModel.eventFlow.test { + viewModel.trySendAction(CardScanAction.CameraSetupErrorReceive) + assertEquals(CardScanEvent.NavigateBack, awaitItem()) + } + + verify(exactly = 1) { + cardScanManager.emitCardScanResult(CardScanResult.ScanError()) + } + } + + @Test + fun `CardScanReceive should emit result and NavigateBack`() = runTest { + val viewModel = createViewModel() + + viewModel.eventFlow.test { + viewModel.trySendAction( + CardScanAction.CardScanReceive( + cardScanData = CARD_SCAN_DATA, + ), + ) + assertEquals(CardScanEvent.NavigateBack, awaitItem()) + } + + verify(exactly = 1) { + cardScanManager.emitCardScanResult( + CardScanResult.Success(cardScanData = CARD_SCAN_DATA), + ) + } + } + + @Test + fun `CardScanReceive should only handle first scan`() = runTest { + val viewModel = createViewModel() + + viewModel.eventFlow.test { + viewModel.trySendAction( + CardScanAction.CardScanReceive( + cardScanData = CARD_SCAN_DATA, + ), + ) + assertEquals(CardScanEvent.NavigateBack, awaitItem()) + + viewModel.trySendAction( + CardScanAction.CardScanReceive( + cardScanData = CARD_SCAN_DATA.copy( + number = "5500000000000004", + ), + ), + ) + expectNoEvents() + } + + verify(exactly = 1) { cardScanManager.emitCardScanResult(any()) } + } + + private fun createViewModel(): CardScanViewModel = + CardScanViewModel( + savedStateHandle = SavedStateHandle(), + cardScanManager = cardScanManager, + ) +} + +private val CARD_SCAN_DATA = CardScanData( + number = "4111111111111111", + expirationMonth = "12", + expirationYear = "2025", + securityCode = "123", +) diff --git a/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt b/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt index 0027f238df3..d8e56071c9f 100644 --- a/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt +++ b/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt @@ -37,6 +37,7 @@ sealed class FlagKey { MigrateMyVaultToMyItems, ArchiveItems, SendEmailVerification, + CardScanner, MobilePremiumUpgrade, AttachmentUpdates, ) @@ -109,6 +110,14 @@ sealed class FlagKey { override val defaultValue: Boolean = false } + /** + * Data object holding the feature flag key for the card scanner feature. + */ + data object CardScanner : FlagKey() { + override val keyName: String = "pm-34171-card-scanner" + override val defaultValue: Boolean = false + } + /** * Data object holding the feature flag key for the mobile Premium upgrade feature. */ diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt index 2e26e29c19d..0e892640c39 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt @@ -30,6 +30,7 @@ fun FlagKey.ListItemContent( FlagKey.NoLogoutOnKdfChange, FlagKey.MigrateMyVaultToMyItems, FlagKey.ArchiveItems, + FlagKey.CardScanner, FlagKey.SendEmailVerification, FlagKey.MobilePremiumUpgrade, FlagKey.AttachmentUpdates, @@ -84,6 +85,7 @@ private fun FlagKey.getDisplayLabel(): String = when (this) { FlagKey.MigrateMyVaultToMyItems -> stringResource(BitwardenString.migrate_my_vault_to_my_items) FlagKey.ArchiveItems -> stringResource(BitwardenString.archive_items) + FlagKey.CardScanner -> stringResource(BitwardenString.scan_card) FlagKey.SendEmailVerification -> stringResource(BitwardenString.send_email_verification) FlagKey.MobilePremiumUpgrade -> stringResource(BitwardenString.mobile_premium_upgrade) FlagKey.AttachmentUpdates -> stringResource(BitwardenString.attachment_updates) diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/manager/CardScanManager.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/manager/CardScanManager.kt new file mode 100644 index 00000000000..5adf2b9a136 --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/manager/CardScanManager.kt @@ -0,0 +1,21 @@ +package com.bitwarden.ui.platform.feature.cardscanner.manager + +import com.bitwarden.ui.platform.feature.cardscanner.util.CardScanResult +import kotlinx.coroutines.flow.Flow + +/** + * Manages the communication of credit card scan results between the card scanner + * screen and the vault add/edit screen. + */ +interface CardScanManager { + + /** + * Flow that emits card scan results. + */ + val cardScanResultFlow: Flow + + /** + * Emits a [CardScanResult] to all active subscribers. + */ + fun emitCardScanResult(cardScanResult: CardScanResult) +} diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/manager/CardScanManagerImpl.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/manager/CardScanManagerImpl.kt new file mode 100644 index 00000000000..1ee8ab062e5 --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/manager/CardScanManagerImpl.kt @@ -0,0 +1,22 @@ +package com.bitwarden.ui.platform.feature.cardscanner.manager + +import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow +import com.bitwarden.ui.platform.feature.cardscanner.util.CardScanResult +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asSharedFlow + +/** + * Primary implementation of [CardScanManager]. + */ +class CardScanManagerImpl : CardScanManager { + + private val mutableCardScanResultFlow = + bufferedMutableSharedFlow() + + override val cardScanResultFlow: Flow + get() = mutableCardScanResultFlow.asSharedFlow() + + override fun emitCardScanResult(cardScanResult: CardScanResult) { + mutableCardScanResultFlow.tryEmit(cardScanResult) + } +} diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardScanResult.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardScanResult.kt new file mode 100644 index 00000000000..a473b3563b5 --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardScanResult.kt @@ -0,0 +1,19 @@ +package com.bitwarden.ui.platform.feature.cardscanner.util + +/** + * Models result of the user scanning a credit card. + */ +sealed class CardScanResult { + + /** + * Card has been successfully scanned with the detected fields. + * + * @property cardScanData The scanned card data. + */ + data class Success(val cardScanData: CardScanData) : CardScanResult() + + /** + * There was an error scanning the card. + */ + data class ScanError(val error: Throwable? = null) : CardScanResult() +} diff --git a/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/manager/CardScanManagerTest.kt b/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/manager/CardScanManagerTest.kt new file mode 100644 index 00000000000..ddfd7dfa9e2 --- /dev/null +++ b/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/manager/CardScanManagerTest.kt @@ -0,0 +1,40 @@ +package com.bitwarden.ui.platform.feature.cardscanner.manager + +import app.cash.turbine.test +import com.bitwarden.ui.platform.feature.cardscanner.util.CardScanData +import com.bitwarden.ui.platform.feature.cardscanner.util.CardScanResult +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class CardScanManagerTest { + + private val manager = CardScanManagerImpl() + + @Test + fun `emitCardScanResult should emit Success to cardScanResultFlow`() = + runTest { + manager.cardScanResultFlow.test { + val expected = CardScanResult.Success( + cardScanData = CardScanData( + number = "4111111111111111", + expirationMonth = "12", + expirationYear = "2025", + securityCode = "123", + ), + ) + manager.emitCardScanResult(expected) + assertEquals(expected, awaitItem()) + } + } + + @Test + fun `emitCardScanResult should emit ScanError to cardScanResultFlow`() = + runTest { + manager.cardScanResultFlow.test { + val expected = CardScanResult.ScanError() + manager.emitCardScanResult(expected) + assertEquals(expected, awaitItem()) + } + } +}