Skip to content

Commit de47c50

Browse files
authored
[PM-34126] feat: Add card scan screen (#6721)
1 parent 31b3b03 commit de47c50

14 files changed

Lines changed: 636 additions & 0 deletions

File tree

app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import com.bitwarden.network.service.CiphersService
1010
import com.bitwarden.network.service.FolderService
1111
import com.bitwarden.network.service.SendsService
1212
import com.bitwarden.network.service.SyncService
13+
import com.bitwarden.ui.platform.feature.cardscanner.manager.CardScanManager
14+
import com.bitwarden.ui.platform.feature.cardscanner.manager.CardScanManagerImpl
1315
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
1416
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
1517
import com.x8bit.bitwarden.data.auth.manager.KdfManager
@@ -60,6 +62,10 @@ import javax.inject.Singleton
6062
@InstallIn(SingletonComponent::class)
6163
object VaultManagerModule {
6264

65+
@Provides
66+
@Singleton
67+
fun provideCardScanManager(): CardScanManager = CardScanManagerImpl()
68+
6369
@Provides
6470
@Singleton
6571
fun provideVaultMigrationManager(

app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,14 @@ import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeImporter
2222
import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeRequestValidator
2323
import com.bitwarden.cxf.validator.CredentialExchangeRequestValidator
2424
import com.bitwarden.cxf.validator.dsl.credentialExchangeRequestValidator
25+
import com.bitwarden.ui.platform.composition.LocalCardTextAnalyzer
2526
import com.bitwarden.ui.platform.composition.LocalExitManager
2627
import com.bitwarden.ui.platform.composition.LocalIntentManager
2728
import com.bitwarden.ui.platform.composition.LocalQrCodeAnalyzer
29+
import com.bitwarden.ui.platform.feature.cardscanner.util.CardDataParser
30+
import com.bitwarden.ui.platform.feature.cardscanner.util.CardDataParserImpl
31+
import com.bitwarden.ui.platform.feature.cardscanner.util.CardTextAnalyzer
32+
import com.bitwarden.ui.platform.feature.cardscanner.util.CardTextAnalyzerImpl
2833
import com.bitwarden.ui.platform.feature.qrcodescan.util.QrCodeAnalyzer
2934
import com.bitwarden.ui.platform.feature.qrcodescan.util.QrCodeAnalyzerImpl
3035
import com.bitwarden.ui.platform.manager.IntentManager
@@ -84,6 +89,10 @@ fun LocalManagerProvider(
8489
credentialExchangeRequestValidator: CredentialExchangeRequestValidator =
8590
credentialExchangeRequestValidator(activity = activity),
8691
authTabLaunchers: AuthTabLaunchers,
92+
cardDataParser: CardDataParser = CardDataParserImpl(),
93+
cardTextAnalyzer: CardTextAnalyzer = CardTextAnalyzerImpl(
94+
cardDataParser = cardDataParser,
95+
),
8796
qrCodeAnalyzer: QrCodeAnalyzer = QrCodeAnalyzerImpl(),
8897
content: @Composable () -> Unit,
8998
) {
@@ -103,6 +112,7 @@ fun LocalManagerProvider(
103112
LocalCredentialExchangeCompletionManager provides credentialExchangeCompletionManager,
104113
LocalCredentialExchangeRequestValidator provides credentialExchangeRequestValidator,
105114
LocalAuthTabLaunchers provides authTabLaunchers,
115+
LocalCardTextAnalyzer provides cardTextAnalyzer,
106116
LocalQrCodeAnalyzer provides qrCodeAnalyzer,
107117
content = content,
108118
)
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
@file:OmitFromCoverage
2+
3+
package com.x8bit.bitwarden.ui.vault.feature.cardscanner
4+
5+
import androidx.navigation.NavController
6+
import androidx.navigation.NavGraphBuilder
7+
import androidx.navigation.NavOptions
8+
import com.bitwarden.annotation.OmitFromCoverage
9+
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
10+
import kotlinx.serialization.Serializable
11+
12+
/**
13+
* The type-safe route for the card scan screen.
14+
*/
15+
@OmitFromCoverage
16+
@Serializable
17+
data object CardScanRoute
18+
19+
/**
20+
* Add the card scan screen to the nav graph.
21+
*/
22+
fun NavGraphBuilder.cardScanDestination(
23+
onNavigateBack: () -> Unit,
24+
) {
25+
composableWithSlideTransitions<CardScanRoute> {
26+
CardScanScreen(
27+
onNavigateBack = onNavigateBack,
28+
)
29+
}
30+
}
31+
32+
/**
33+
* Navigate to the card scan screen.
34+
*/
35+
fun NavController.navigateToCardScanScreen(
36+
navOptions: NavOptions? = null,
37+
) {
38+
this.navigate(route = CardScanRoute, navOptions = navOptions)
39+
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package com.x8bit.bitwarden.ui.vault.feature.cardscanner
2+
3+
import androidx.compose.foundation.background
4+
import androidx.compose.foundation.layout.Arrangement
5+
import androidx.compose.foundation.layout.Column
6+
import androidx.compose.foundation.layout.Row
7+
import androidx.compose.foundation.layout.Spacer
8+
import androidx.compose.foundation.layout.fillMaxSize
9+
import androidx.compose.foundation.layout.navigationBarsPadding
10+
import androidx.compose.foundation.layout.padding
11+
import androidx.compose.foundation.rememberScrollState
12+
import androidx.compose.foundation.verticalScroll
13+
import androidx.compose.material3.ExperimentalMaterial3Api
14+
import androidx.compose.material3.Text
15+
import androidx.compose.material3.TopAppBarDefaults
16+
import androidx.compose.material3.rememberTopAppBarState
17+
import androidx.compose.runtime.Composable
18+
import androidx.compose.runtime.CompositionLocalProvider
19+
import androidx.compose.ui.Alignment
20+
import androidx.compose.ui.Modifier
21+
import androidx.compose.ui.res.stringResource
22+
import androidx.compose.ui.text.style.TextAlign
23+
import androidx.compose.ui.unit.dp
24+
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
25+
import com.bitwarden.ui.platform.base.util.EventsEffect
26+
import com.bitwarden.ui.platform.base.util.StatusBarsAppearanceAffect
27+
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
28+
import com.bitwarden.ui.platform.components.camera.CameraPreview
29+
import com.bitwarden.ui.platform.components.camera.CardScanOverlay
30+
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
31+
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
32+
import com.bitwarden.ui.platform.composition.LocalCardTextAnalyzer
33+
import com.bitwarden.ui.platform.feature.cardscanner.util.CardTextAnalyzer
34+
import com.bitwarden.ui.platform.model.WindowSize
35+
import com.bitwarden.ui.platform.resource.BitwardenDrawable
36+
import com.bitwarden.ui.platform.resource.BitwardenString
37+
import com.bitwarden.ui.platform.theme.BitwardenTheme
38+
import com.bitwarden.ui.platform.theme.LocalBitwardenColorScheme
39+
import com.bitwarden.ui.platform.theme.color.darkBitwardenColorScheme
40+
import com.bitwarden.ui.platform.util.rememberWindowSize
41+
42+
/**
43+
* The screen to scan credit cards for the application.
44+
*/
45+
@Suppress("LongMethod")
46+
@OptIn(ExperimentalMaterial3Api::class)
47+
@Composable
48+
fun CardScanScreen(
49+
onNavigateBack: () -> Unit,
50+
viewModel: CardScanViewModel = hiltViewModel(),
51+
cardTextAnalyzer: CardTextAnalyzer = LocalCardTextAnalyzer.current,
52+
) {
53+
cardTextAnalyzer.onCardScanned = { cardScanData ->
54+
viewModel.trySendAction(
55+
CardScanAction.CardScanReceive(cardScanData = cardScanData),
56+
)
57+
}
58+
59+
EventsEffect(viewModel = viewModel) { event ->
60+
when (event) {
61+
is CardScanEvent.NavigateBack -> onNavigateBack()
62+
}
63+
}
64+
65+
// This screen should always look like it's in dark mode
66+
CompositionLocalProvider(
67+
LocalBitwardenColorScheme provides darkBitwardenColorScheme,
68+
) {
69+
StatusBarsAppearanceAffect()
70+
BitwardenScaffold(
71+
modifier = Modifier.fillMaxSize(),
72+
topBar = {
73+
BitwardenTopAppBar(
74+
title = stringResource(id = BitwardenString.scan_card),
75+
navigationIcon = rememberVectorPainter(
76+
id = BitwardenDrawable.ic_close,
77+
),
78+
navigationIconContentDescription = stringResource(
79+
id = BitwardenString.close,
80+
),
81+
onNavigationIconClick = {
82+
viewModel.trySendAction(CardScanAction.CloseClick)
83+
},
84+
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(
85+
state = rememberTopAppBarState(),
86+
),
87+
)
88+
},
89+
) {
90+
CameraPreview(
91+
cameraErrorReceive = {
92+
viewModel.trySendAction(
93+
CardScanAction.CameraSetupErrorReceive,
94+
)
95+
},
96+
analyzer = cardTextAnalyzer,
97+
modifier = Modifier.fillMaxSize(),
98+
)
99+
when (rememberWindowSize()) {
100+
WindowSize.Compact -> {
101+
CardScanContentCompact()
102+
}
103+
104+
WindowSize.Medium -> {
105+
CardScanContentMedium()
106+
}
107+
}
108+
}
109+
}
110+
}
111+
112+
@Composable
113+
private fun CardScanContentCompact(
114+
modifier: Modifier = Modifier,
115+
) {
116+
Column(
117+
horizontalAlignment = Alignment.CenterHorizontally,
118+
modifier = modifier,
119+
) {
120+
CardScanOverlay(
121+
overlayWidth = 300.dp,
122+
modifier = Modifier.weight(2f),
123+
)
124+
125+
Column(
126+
horizontalAlignment = Alignment.CenterHorizontally,
127+
verticalArrangement = Arrangement.SpaceAround,
128+
modifier = Modifier
129+
.weight(1f)
130+
.fillMaxSize()
131+
.background(color = BitwardenTheme.colorScheme.background.scrim)
132+
.padding(horizontal = 16.dp)
133+
.verticalScroll(rememberScrollState()),
134+
) {
135+
Text(
136+
text = stringResource(
137+
id = BitwardenString.scan_card_instruction,
138+
),
139+
textAlign = TextAlign.Center,
140+
color = BitwardenTheme.colorScheme.text.primary,
141+
style = BitwardenTheme.typography.bodyMedium,
142+
modifier = Modifier.padding(horizontal = 16.dp),
143+
)
144+
Spacer(modifier = Modifier.navigationBarsPadding())
145+
}
146+
}
147+
}
148+
149+
@Composable
150+
private fun CardScanContentMedium(
151+
modifier: Modifier = Modifier,
152+
) {
153+
Row(
154+
verticalAlignment = Alignment.CenterVertically,
155+
modifier = modifier,
156+
) {
157+
CardScanOverlay(
158+
overlayWidth = 250.dp,
159+
modifier = Modifier.weight(2f),
160+
)
161+
162+
Column(
163+
horizontalAlignment = Alignment.CenterHorizontally,
164+
verticalArrangement = Arrangement.SpaceAround,
165+
modifier = Modifier
166+
.weight(1f)
167+
.fillMaxSize()
168+
.background(color = BitwardenTheme.colorScheme.background.scrim)
169+
.padding(horizontal = 16.dp)
170+
.navigationBarsPadding()
171+
.verticalScroll(rememberScrollState()),
172+
) {
173+
Text(
174+
text = stringResource(
175+
id = BitwardenString.scan_card_instruction,
176+
),
177+
textAlign = TextAlign.Center,
178+
color = BitwardenTheme.colorScheme.text.primary,
179+
style = BitwardenTheme.typography.bodySmall,
180+
)
181+
}
182+
}
183+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package com.x8bit.bitwarden.ui.vault.feature.cardscanner
2+
3+
import android.os.Parcelable
4+
import androidx.lifecycle.SavedStateHandle
5+
import com.bitwarden.ui.platform.base.BaseViewModel
6+
import com.bitwarden.ui.platform.base.DeferredBackgroundEvent
7+
import com.bitwarden.ui.platform.feature.cardscanner.manager.CardScanManager
8+
import com.bitwarden.ui.platform.feature.cardscanner.util.CardScanData
9+
import com.bitwarden.ui.platform.feature.cardscanner.util.CardScanResult
10+
import dagger.hilt.android.lifecycle.HiltViewModel
11+
import kotlinx.coroutines.flow.update
12+
import kotlinx.parcelize.Parcelize
13+
import javax.inject.Inject
14+
15+
private const val KEY_STATE = "state"
16+
17+
/**
18+
* Handles [CardScanAction] and launches [CardScanEvent] for the [CardScanScreen].
19+
*/
20+
@HiltViewModel
21+
class CardScanViewModel @Inject constructor(
22+
savedStateHandle: SavedStateHandle,
23+
private val cardScanManager: CardScanManager,
24+
) : BaseViewModel<CardScanState, CardScanEvent, CardScanAction>(
25+
initialState = savedStateHandle[KEY_STATE]
26+
?: CardScanState(hasHandledScan = false),
27+
) {
28+
29+
override fun handleAction(action: CardScanAction) {
30+
when (action) {
31+
is CardScanAction.CloseClick -> handleCloseClick()
32+
is CardScanAction.CameraSetupErrorReceive -> handleCameraErrorReceive()
33+
is CardScanAction.CardScanReceive -> handleCardScanReceive(action)
34+
}
35+
}
36+
37+
private fun handleCloseClick() {
38+
sendEvent(CardScanEvent.NavigateBack)
39+
}
40+
41+
private fun handleCameraErrorReceive() {
42+
cardScanManager.emitCardScanResult(CardScanResult.ScanError())
43+
sendEvent(CardScanEvent.NavigateBack)
44+
}
45+
46+
private fun handleCardScanReceive(action: CardScanAction.CardScanReceive) {
47+
if (state.hasHandledScan) return
48+
mutableStateFlow.update { it.copy(hasHandledScan = true) }
49+
cardScanManager.emitCardScanResult(
50+
CardScanResult.Success(cardScanData = action.cardScanData),
51+
)
52+
sendEvent(CardScanEvent.NavigateBack)
53+
}
54+
}
55+
56+
/**
57+
* Models events for the [CardScanScreen].
58+
*/
59+
sealed class CardScanEvent {
60+
61+
/**
62+
* Navigate back. Added [DeferredBackgroundEvent] as scan might fire before
63+
* events are consumed.
64+
*/
65+
data object NavigateBack : CardScanEvent(), DeferredBackgroundEvent
66+
}
67+
68+
/**
69+
* Models actions for the [CardScanScreen].
70+
*/
71+
sealed class CardScanAction {
72+
73+
/**
74+
* User clicked close.
75+
*/
76+
data object CloseClick : CardScanAction()
77+
78+
/**
79+
* A card has been scanned with the detected fields.
80+
*/
81+
data class CardScanReceive(
82+
val cardScanData: CardScanData,
83+
) : CardScanAction()
84+
85+
/**
86+
* The camera is unable to be set up.
87+
*/
88+
data object CameraSetupErrorReceive : CardScanAction()
89+
}
90+
91+
/**
92+
* Represents the state of the card scan screen.
93+
*/
94+
@Parcelize
95+
data class CardScanState(
96+
val hasHandledScan: Boolean,
97+
) : Parcelable

app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import com.bitwarden.cxf.importer.CredentialExchangeImporter
55
import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager
66
import com.bitwarden.cxf.validator.CredentialExchangeRequestValidator
77
import com.bitwarden.ui.platform.base.BaseComposeTest
8+
import com.bitwarden.ui.platform.feature.cardscanner.util.CardTextAnalyzer
89
import com.bitwarden.ui.platform.feature.qrcodescan.util.QrCodeAnalyzer
910
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
1011
import com.bitwarden.ui.platform.manager.IntentManager
@@ -49,6 +50,7 @@ abstract class BitwardenComposeTest : BaseComposeTest() {
4950
credentialExchangeImporter: CredentialExchangeImporter = mockk(),
5051
credentialExchangeCompletionManager: CredentialExchangeCompletionManager = mockk(),
5152
credentialExchangeRequestValidator: CredentialExchangeRequestValidator = mockk(),
53+
cardTextAnalyzer: CardTextAnalyzer = mockk(),
5254
qrCodeAnalyzer: QrCodeAnalyzer = mockk(),
5355
test: @Composable () -> Unit,
5456
) {
@@ -69,6 +71,7 @@ abstract class BitwardenComposeTest : BaseComposeTest() {
6971
credentialExchangeImporter = credentialExchangeImporter,
7072
credentialExchangeCompletionManager = credentialExchangeCompletionManager,
7173
credentialExchangeRequestValidator = credentialExchangeRequestValidator,
74+
cardTextAnalyzer = cardTextAnalyzer,
7275
qrCodeAnalyzer = qrCodeAnalyzer,
7376
) {
7477
BitwardenTheme(

0 commit comments

Comments
 (0)