[PM-34126] feat: Add card scan screen (#6721)

This commit is contained in:
Patrick Honkonen
2026-04-07 15:47:07 -04:00
committed by GitHub
parent 31b3b0304c
commit de47c507cc
14 changed files with 636 additions and 0 deletions

View File

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

View File

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

View File

@@ -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<CardScanRoute> {
CardScanScreen(
onNavigateBack = onNavigateBack,
)
}
}
/**
* Navigate to the card scan screen.
*/
fun NavController.navigateToCardScanScreen(
navOptions: NavOptions? = null,
) {
this.navigate(route = CardScanRoute, navOptions = navOptions)
}

View File

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

View File

@@ -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<CardScanState, CardScanEvent, CardScanAction>(
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

View File

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

View File

@@ -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<CardScanEvent>()
private val viewModel = mockk<CardScanViewModel>(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()
}
}

View File

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

View File

@@ -37,6 +37,7 @@ sealed class FlagKey<out T : Any> {
MigrateMyVaultToMyItems,
ArchiveItems,
SendEmailVerification,
CardScanner,
MobilePremiumUpgrade,
AttachmentUpdates,
)
@@ -109,6 +110,14 @@ sealed class FlagKey<out T : Any> {
override val defaultValue: Boolean = false
}
/**
* Data object holding the feature flag key for the card scanner feature.
*/
data object CardScanner : FlagKey<Boolean>() {
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.
*/

View File

@@ -30,6 +30,7 @@ fun <T : Any> FlagKey<T>.ListItemContent(
FlagKey.NoLogoutOnKdfChange,
FlagKey.MigrateMyVaultToMyItems,
FlagKey.ArchiveItems,
FlagKey.CardScanner,
FlagKey.SendEmailVerification,
FlagKey.MobilePremiumUpgrade,
FlagKey.AttachmentUpdates,
@@ -84,6 +85,7 @@ private fun <T : Any> FlagKey<T>.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)

View File

@@ -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<CardScanResult>
/**
* Emits a [CardScanResult] to all active subscribers.
*/
fun emitCardScanResult(cardScanResult: CardScanResult)
}

View File

@@ -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<CardScanResult>()
override val cardScanResultFlow: Flow<CardScanResult>
get() = mutableCardScanResultFlow.asSharedFlow()
override fun emitCardScanResult(cardScanResult: CardScanResult) {
mutableCardScanResultFlow.tryEmit(cardScanResult)
}
}

View File

@@ -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()
}

View File

@@ -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())
}
}
}