mirror of
https://github.com/bitwarden/android.git
synced 2026-05-07 11:29:37 -05:00
[PM-34126] feat: Add card scan screen (#6721)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user