BIT-1101, BIT-1066, BIT-1071, BIT-1072 Adding QR code scanning feature (#464)

This commit is contained in:
Oleg Semenenko 2024-01-02 17:29:48 -06:00 committed by Álison Fernandes
parent 1c8501b69b
commit e929641159
28 changed files with 1212 additions and 62 deletions

View file

@ -67,6 +67,11 @@ The following is a list of all third-party dependencies included as part of the
- Purpose: Displays webpages with the user's default browser.
- License: Apache 2.0
- **AndroidX Camera**
- https://developer.android.com/jetpack/androidx/releases/camera
- Purpose: Display and capture images for barcode scanning.
- License: Apache 2.0
- **Core SplashScreen**
- https://developer.android.com/jetpack/androidx/releases/core
- Purpose: Backwards compatible SplashScreen API implementation.

View file

@ -101,6 +101,9 @@ dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.browser)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.animation)
implementation(libs.androidx.compose.material3)

View file

@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.data.vault.repository.model.VaultState
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
/**
@ -61,6 +62,11 @@ interface VaultRepository {
*/
val sendDataStateFlow: StateFlow<DataState<SendData>>
/**
* Flow that represents the totp code.
*/
val totpCodeFlow: Flow<String>
/**
* Clear any previously unlocked, in-memory data (vault, send, etc).
*/
@ -98,6 +104,11 @@ interface VaultRepository {
*/
fun lockVaultIfNecessary(userId: String)
/**
* Emits the totp code flow to listeners.
*/
fun emitTotpCode(totpCode: String)
/**
* Attempt to unlock the vault and sync the vault data for the currently active user.
*/

View file

@ -13,6 +13,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.toUpdatedUserStateJson
import com.x8bit.bitwarden.data.platform.datasource.network.util.isNoConnectionError
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.platform.repository.util.combineDataStates
import com.x8bit.bitwarden.data.platform.repository.util.map
import com.x8bit.bitwarden.data.platform.repository.util.observeWhenSubscribedAndLoggedIn
@ -43,6 +44,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
@ -84,6 +86,8 @@ class VaultRepositoryImpl(
private val activeUserId: String? get() = authDiskSource.userState?.activeUserId
private val mutableTotpCodeFlow = bufferedMutableSharedFlow<String>()
private val mutableVaultStateStateFlow =
MutableStateFlow(VaultState(unlockedVaultUserIds = emptySet()))
@ -122,6 +126,9 @@ class VaultRepositoryImpl(
initialValue = DataState.Loading,
)
override val totpCodeFlow: Flow<String>
get() = mutableTotpCodeFlow.asSharedFlow()
override val ciphersStateFlow: StateFlow<DataState<List<CipherView>>>
get() = mutableCiphersStateFlow.asStateFlow()
@ -261,6 +268,10 @@ class VaultRepositoryImpl(
setVaultToLocked(userId = userId)
}
override fun emitTotpCode(totpCode: String) {
mutableTotpCodeFlow.tryEmit(totpCode)
}
@Suppress("ReturnCount")
override suspend fun unlockVaultAndSyncForCurrentUser(
masterPassword: String,

View file

@ -18,6 +18,8 @@ import com.x8bit.bitwarden.ui.vault.feature.additem.navigateToVaultAddEditItem
import com.x8bit.bitwarden.ui.vault.feature.additem.vaultAddEditItemDestination
import com.x8bit.bitwarden.ui.vault.feature.item.navigateToVaultItem
import com.x8bit.bitwarden.ui.vault.feature.item.vaultItemDestination
import com.x8bit.bitwarden.ui.vault.feature.qrcodescan.navigateToQrCodeScanScreen
import com.x8bit.bitwarden.ui.vault.feature.qrcodescan.vaultQrCodeScanDestination
import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType
const val VAULT_UNLOCKED_GRAPH_ROUTE: String = "vault_unlocked_graph"
@ -53,13 +55,19 @@ fun NavGraphBuilder.vaultUnlockedGraph(
onNavigateToPasswordHistory = { navController.navigateToPasswordHistory() },
)
deleteAccountDestination(onNavigateBack = { navController.popBackStack() })
vaultAddEditItemDestination(onNavigateBack = { navController.popBackStack() })
vaultAddEditItemDestination(
onNavigateToQrCodeScanScreen = {
navController.navigateToQrCodeScanScreen()
},
onNavigateBack = { navController.popBackStack() },
)
vaultItemDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToVaultEditItem = {
navController.navigateToVaultAddEditItem(VaultAddEditType.EditItem(it))
},
)
vaultQrCodeScanDestination(onNavigateBack = { navController.popBackStack() })
addSendDestination(onNavigateBack = { navController.popBackStack() })
passwordHistoryDestination(onNavigateBack = { navController.popBackStack() })
foldersDestination(onNavigateBack = { navController.popBackStack() })

View file

@ -165,7 +165,12 @@ val LocalNonMaterialColors: ProvidableCompositionLocal<NonMaterialColors> =
compositionLocalOf {
// Default value here will immediately be overridden in BitwardenTheme, similar
// to how MaterialTheme works.
NonMaterialColors(Color.Transparent, Color.Transparent, Color.Transparent)
NonMaterialColors(
fingerprint = Color.Transparent,
passwordWeak = Color.Transparent,
passwordStrong = Color.Transparent,
qrCodeClickableText = Color.Transparent,
)
}
/**
@ -175,6 +180,7 @@ data class NonMaterialColors(
val fingerprint: Color,
val passwordWeak: Color,
val passwordStrong: Color,
val qrCodeClickableText: Color,
)
private fun lightNonMaterialColors(context: Context): NonMaterialColors =
@ -182,6 +188,7 @@ private fun lightNonMaterialColors(context: Context): NonMaterialColors =
fingerprint = R.color.light_fingerprint.toColor(context),
passwordWeak = R.color.light_password_strength_weak.toColor(context),
passwordStrong = R.color.light_password_strength_strong.toColor(context),
qrCodeClickableText = R.color.qr_code_clickable_text.toColor(context),
)
private fun darkNonMaterialColors(context: Context): NonMaterialColors =
@ -189,4 +196,5 @@ private fun darkNonMaterialColors(context: Context): NonMaterialColors =
fingerprint = R.color.dark_fingerprint.toColor(context),
passwordWeak = R.color.dark_password_strength_weak.toColor(context),
passwordStrong = R.color.dark_password_strength_strong.toColor(context),
qrCodeClickableText = R.color.qr_code_clickable_text.toColor(context),
)

View file

@ -110,16 +110,49 @@ fun LazyListScope.addEditLoginItems(
)
}
item {
Spacer(modifier = Modifier.height(16.dp))
BitwardenFilledTonalButtonWithIcon(
label = stringResource(id = R.string.setup_totp),
icon = painterResource(id = R.drawable.ic_light_bulb),
onClick = onTotpSetupClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
if (loginState.totp != null) {
item {
BitwardenTextFieldWithActions(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
label = stringResource(id = R.string.totp),
value = loginState.totp,
onValueChange = {},
readOnly = true,
singleLine = true,
actions = {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_copy),
contentDescription = stringResource(id = R.string.copy_totp),
),
onClick = {
loginItemTypeHandlers.onCopyTotpKeyClick(loginState.totp)
},
)
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_camera),
contentDescription = stringResource(id = R.string.camera),
),
onClick = onTotpSetupClick,
)
},
)
}
} else {
item {
Spacer(modifier = Modifier.height(16.dp))
BitwardenFilledTonalButtonWithIcon(
label = stringResource(id = R.string.setup_totp),
icon = painterResource(id = R.drawable.ic_light_bulb),
onClick = onTotpSetupClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
item {

View file

@ -40,6 +40,7 @@ data class VaultAddEditItemArgs(
* Add the vault add & edit item screen to the nav graph.
*/
fun NavGraphBuilder.vaultAddEditItemDestination(
onNavigateToQrCodeScanScreen: () -> Unit,
onNavigateBack: () -> Unit,
) {
composableWithSlideTransitions(
@ -48,7 +49,7 @@ fun NavGraphBuilder.vaultAddEditItemDestination(
navArgument(ADD_EDIT_ITEM_TYPE) { type = NavType.StringType },
),
) {
VaultAddItemScreen(onNavigateBack)
VaultAddItemScreen(onNavigateBack, onNavigateToQrCodeScanScreen)
}
}

View file

@ -13,6 +13,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -23,6 +25,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.PermissionsManager
import com.x8bit.bitwarden.ui.platform.base.util.PermissionsManagerImpl
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
@ -42,17 +45,28 @@ import com.x8bit.bitwarden.ui.vault.feature.additem.handlers.VaultAddLoginItemTy
@Composable
fun VaultAddItemScreen(
onNavigateBack: () -> Unit,
onNavigateToQrCodeScanScreen: () -> Unit,
viewModel: VaultAddItemViewModel = hiltViewModel(),
clipboardManager: ClipboardManager = LocalClipboardManager.current,
permissionsManager: PermissionsManager =
PermissionsManagerImpl(LocalContext.current as Activity),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
val resources = context.resources
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is VaultAddItemEvent.NavigateToQrCodeScan -> {
onNavigateToQrCodeScanScreen()
}
is VaultAddItemEvent.ShowToast -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show()
}
is VaultAddItemEvent.CopyToClipboard -> {
clipboardManager.setText(event.text.toAnnotatedString())
}
VaultAddItemEvent.NavigateBack -> onNavigateBack.invoke()

View file

@ -81,6 +81,12 @@ class VaultAddItemViewModel @Inject constructor(
.launchIn(viewModelScope)
}
}
vaultRepository
.totpCodeFlow
.map { VaultAddItemAction.Internal.TotpCodeReceive(totpCode = it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: VaultAddItemAction) {
@ -294,7 +300,7 @@ class VaultAddItemViewModel @Inject constructor(
// TODO Add the text for the prompt (BIT-1079)
sendEvent(
event = VaultAddItemEvent.ShowToast(
message = "Not yet implemented",
message = "Not yet implemented".asText(),
),
)
}
@ -343,6 +349,10 @@ class VaultAddItemViewModel @Inject constructor(
is VaultAddItemAction.ItemType.LoginType.AddNewUriClick -> {
handleLoginAddNewUriClick()
}
is VaultAddItemAction.ItemType.LoginType.CopyTotpKeyClick -> {
handleLoginCopyTotpKeyText(action)
}
}
}
@ -374,7 +384,7 @@ class VaultAddItemViewModel @Inject constructor(
viewModelScope.launch {
sendEvent(
event = VaultAddItemEvent.ShowToast(
message = "Open Username Generator",
message = "Open Username Generator".asText(),
),
)
}
@ -384,7 +394,7 @@ class VaultAddItemViewModel @Inject constructor(
viewModelScope.launch {
sendEvent(
event = VaultAddItemEvent.ShowToast(
message = "Password Checker",
message = "Password Checker".asText(),
),
)
}
@ -394,7 +404,7 @@ class VaultAddItemViewModel @Inject constructor(
viewModelScope.launch {
sendEvent(
event = VaultAddItemEvent.ShowToast(
message = "Open Password Generator",
message = "Open Password Generator".asText(),
),
)
}
@ -403,25 +413,34 @@ class VaultAddItemViewModel @Inject constructor(
private fun handleLoginSetupTotpClick(
action: VaultAddItemAction.ItemType.LoginType.SetupTotpClick,
) {
viewModelScope.launch {
val message = if (action.isGranted) {
"Permission Granted, QR Code Scanner Not Implemented"
} else {
"Permission Not Granted, Manual QR Code Entry Not Implemented"
}
if (action.isGranted) {
sendEvent(event = VaultAddItemEvent.NavigateToQrCodeScan)
} else {
// TODO Add manual QR code entry (BIT-1114)
sendEvent(
event = VaultAddItemEvent.ShowToast(
message = message,
message =
"Permission Not Granted, Manual QR Code Entry Not Implemented".asText(),
),
)
}
}
private fun handleLoginCopyTotpKeyText(
action: VaultAddItemAction.ItemType.LoginType.CopyTotpKeyClick,
) {
sendEvent(
event = VaultAddItemEvent.CopyToClipboard(
text = action.totpKey,
),
)
}
private fun handleLoginUriSettingsClick() {
viewModelScope.launch {
sendEvent(
event = VaultAddItemEvent.ShowToast(
message = "URI Settings",
message = "URI Settings".asText(),
),
)
}
@ -431,7 +450,7 @@ class VaultAddItemViewModel @Inject constructor(
viewModelScope.launch {
sendEvent(
event = VaultAddItemEvent.ShowToast(
message = "Add New URI",
message = "Add New URI".asText(),
),
)
}
@ -638,6 +657,7 @@ class VaultAddItemViewModel @Inject constructor(
}
is VaultAddItemAction.Internal.VaultDataReceive -> handleVaultDataReceive(action)
is VaultAddItemAction.Internal.TotpCodeReceive -> handleVaultTotpCodeReceive(action)
}
}
@ -653,7 +673,7 @@ class VaultAddItemViewModel @Inject constructor(
// TODO Display error dialog BIT-501
sendEvent(
event = VaultAddItemEvent.ShowToast(
message = "Save Item Failure",
message = "Save Item Failure".asText(),
),
)
}
@ -673,7 +693,7 @@ class VaultAddItemViewModel @Inject constructor(
when (action.updateCipherResult) {
is UpdateCipherResult.Error -> {
// TODO Display error dialog BIT-501
sendEvent(VaultAddItemEvent.ShowToast(message = "Save Item Failure"))
sendEvent(VaultAddItemEvent.ShowToast(message = "Save Item Failure".asText()))
}
is UpdateCipherResult.Success -> {
@ -740,6 +760,18 @@ class VaultAddItemViewModel @Inject constructor(
}
}
private fun handleVaultTotpCodeReceive(action: VaultAddItemAction.Internal.TotpCodeReceive) {
updateLoginContent { loginType ->
loginType.copy(totp = action.totpCode)
}
sendEvent(
event = VaultAddItemEvent.ShowToast(
message = R.string.authenticator_key_added.asText(),
),
)
}
//endregion Internal Type Handlers
//region Utility Functions
@ -929,6 +961,7 @@ data class VaultAddItemState(
val username: String = "",
val password: String = "",
val uri: String = "",
val totp: String? = null,
) : ItemType() {
override val displayStringResId: Int get() = ItemTypeOption.LOGIN.labelRes
}
@ -1090,12 +1123,22 @@ sealed class VaultAddItemEvent {
/**
* Shows a toast with the given [message].
*/
data class ShowToast(val message: String) : VaultAddItemEvent()
data class ShowToast(val message: Text) : VaultAddItemEvent()
/**
* Copy the given [text] to the clipboard.
*/
data class CopyToClipboard(val text: String) : VaultAddItemEvent()
/**
* Navigate back to previous screen.
*/
data object NavigateBack : VaultAddItemEvent()
/**
* Navigate to the QR code scan screen.
*/
data object NavigateToQrCodeScan : VaultAddItemEvent()
}
/**
@ -1229,10 +1272,17 @@ sealed class VaultAddItemAction {
/**
* Represents the action to set up TOTP.
*
* @property isGranted the status of the camera permission
* @property isGranted the status of the camera permission.
*/
data class SetupTotpClick(val isGranted: Boolean) : LoginType()
/**
* Represents the action to copy the totp code to the clipboard.
*
* @property totpKey the totp key being copied.
*/
data class CopyTotpKeyClick(val totpKey: String) : LoginType()
/**
* Represents the action to open the username generator.
*/
@ -1398,6 +1448,12 @@ sealed class VaultAddItemAction {
* Models actions that the [VaultAddItemViewModel] itself might send.
*/
sealed class Internal : VaultAddItemAction() {
/**
* Indicates that the vault totp code has been received.
*/
data class TotpCodeReceive(val totpCode: String) : Internal()
/**
* Indicates that the vault item data has been received.
*/

View file

@ -30,6 +30,7 @@ class VaultAddLoginItemTypeHandlers(
val onPasswordCheckerClick: () -> Unit,
val onOpenPasswordGeneratorClick: () -> Unit,
val onSetupTotpClick: (Boolean) -> Unit,
val onCopyTotpKeyClick: (String) -> Unit,
val onUriSettingsClick: () -> Unit,
val onAddNewUriClick: () -> Unit,
) {
@ -86,6 +87,13 @@ class VaultAddLoginItemTypeHandlers(
onAddNewUriClick = {
viewModel.trySendAction(VaultAddItemAction.ItemType.LoginType.AddNewUriClick)
},
onCopyTotpKeyClick = { totpKey ->
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.CopyTotpKeyClick(
totpKey,
),
)
},
)
}
}

View file

@ -22,6 +22,7 @@ fun CipherView.toViewState(): VaultAddItemState.ViewState =
username = login?.username.orEmpty(),
password = login?.password.orEmpty(),
uri = login?.uris?.firstOrNull()?.uri.orEmpty(),
totp = login?.totp,
)
}

View file

@ -0,0 +1,30 @@
package com.x8bit.bitwarden.ui.vault.feature.qrcodescan
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
private const val QR_CODE_SCAN_ROUTE: String = "qr_code_scan"
/**
* Add the QR code scan screen to the nav graph.
*/
fun NavGraphBuilder.vaultQrCodeScanDestination(
onNavigateBack: () -> Unit,
) {
composableWithSlideTransitions(
route = QR_CODE_SCAN_ROUTE,
) {
QrCodeScanScreen(onNavigateBack)
}
}
/**
* Navigate to the QR code scan screen.
*/
fun NavController.navigateToQrCodeScanScreen(
navOptions: NavOptions? = null,
) {
this.navigate(QR_CODE_SCAN_ROUTE, navOptions)
}

View file

@ -0,0 +1,342 @@
package com.x8bit.bitwarden.ui.vault.feature.qrcodescan
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.Toast
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialColors
import com.x8bit.bitwarden.ui.vault.feature.qrcodescan.util.QrCodeAnalyzer
import com.x8bit.bitwarden.ui.vault.feature.qrcodescan.util.QrCodeAnalyzerImpl
import java.util.concurrent.Executors
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
/**
* The screen to scan QR codes for the application.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun QrCodeScanScreen(
onNavigateBack: () -> Unit,
viewModel: QrCodeScanViewModel = hiltViewModel(),
qrCodeAnalyzer: QrCodeAnalyzer = QrCodeAnalyzerImpl(),
) {
qrCodeAnalyzer.onQrCodeScanned = remember(viewModel) {
{ viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(it)) }
}
val context = LocalContext.current
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is QrCodeScanEvent.ShowToast -> {
Toast
.makeText(context, event.message.invoke(context.resources), Toast.LENGTH_SHORT)
.show()
}
is QrCodeScanEvent.NavigateBack -> {
onNavigateBack.invoke()
}
}
}
BitwardenScaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.scan_qr_code),
navigationIcon = painterResource(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(QrCodeScanAction.CloseClick) }
},
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
)
},
) { innerPadding ->
CameraPreview(
cameraErrorReceive = remember(viewModel) {
{ viewModel.trySendAction(QrCodeScanAction.CameraSetupErrorReceive) }
},
qrCodeAnalyzer = qrCodeAnalyzer,
modifier = Modifier
.padding(innerPadding),
)
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
QrCodeSquare(modifier = Modifier.weight(2f))
Column(
verticalArrangement = Arrangement.SpaceEvenly,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.weight(1f)
.background(color = Color.Black.copy(alpha = .4f)),
) {
Spacer(modifier = Modifier.height(40.dp))
Text(
modifier = Modifier
.padding(horizontal = 32.dp)
.weight(1f),
text = stringResource(id = R.string.point_your_camera_at_the_qr_code),
textAlign = TextAlign.Center,
color = Color.White,
style = MaterialTheme.typography.bodyMedium,
)
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.weight(1f),
textAlign = TextAlign.End,
text = stringResource(id = R.string.cannot_scan_qr_code),
color = Color.White,
style = MaterialTheme.typography.bodyMedium,
)
ClickableText(
modifier = Modifier
.weight(1f)
.padding(start = 8.dp),
onClick = remember(viewModel) {
{ viewModel.trySendAction(QrCodeScanAction.ManualEntryTextClick) }
},
text = stringResource(id = R.string.enter_key_manually).toAnnotatedString(),
style = MaterialTheme.typography.bodyMedium.copy(
color = LocalNonMaterialColors.current.qrCodeClickableText,
),
)
}
}
}
}
}
@Suppress("LongMethod", "TooGenericExceptionCaught")
@Composable
private fun CameraPreview(
cameraErrorReceive: () -> Unit,
qrCodeAnalyzer: QrCodeAnalyzer,
modifier: Modifier,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var cameraProvider: ProcessCameraProvider? by remember { mutableStateOf(null) }
val previewView = remember {
PreviewView(context).apply {
scaleType = PreviewView.ScaleType.FILL_CENTER
layoutParams = ViewGroup.LayoutParams(
MATCH_PARENT,
MATCH_PARENT,
)
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
}
}
val imageAnalyzer = remember(qrCodeAnalyzer) {
ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.apply {
setAnalyzer(
Executors.newSingleThreadExecutor(),
qrCodeAnalyzer,
)
}
}
val preview = Preview.Builder()
.build()
.apply { setSurfaceProvider(previewView.surfaceProvider) }
// Unbind from the camera provider when we leave the screen.
DisposableEffect(Unit) {
onDispose {
cameraProvider?.unbindAll()
}
}
// Set up the camera provider on a background thread. This is necessary because
// ProcessCameraProvider.getInstance returns a ListenableFuture. For an example see
// https://github.com/JetBrains/compose-multiplatform/blob/1c7154b975b79901f40f28278895183e476ed36d/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/CameraView.android.kt#L85
LaunchedEffect(imageAnalyzer) {
try {
cameraProvider = suspendCoroutine { continuation ->
ProcessCameraProvider.getInstance(context).also { future ->
future.addListener(
{ continuation.resume(future.get()) },
Executors.newSingleThreadExecutor(),
)
}
}
cameraProvider?.unbindAll()
cameraProvider?.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
imageAnalyzer,
)
} catch (e: Exception) {
cameraErrorReceive()
}
}
AndroidView(
factory = { previewView },
modifier = modifier,
)
}
/**
* UI for the blue QR code square that is drawn onto the screen.
*/
@Suppress("MagicNumber", "LongMethod")
@Composable
private fun QrCodeSquare(modifier: Modifier = Modifier) {
val color = MaterialTheme.colorScheme.primary
Box(
contentAlignment = Alignment.Center,
modifier = modifier,
) {
Canvas(
modifier = Modifier
.size(250.dp)
.padding(8.dp),
) {
val strokeWidth = 3.dp.toPx()
val squareSize = size.width
val strokeOffset = strokeWidth / 2
val sideLength = (1f / 6) * squareSize
drawIntoCanvas { canvas ->
canvas.nativeCanvas.apply {
// Draw upper top left.
drawLine(
color = color,
start = Offset(0f, strokeOffset),
end = Offset(sideLength, strokeOffset),
strokeWidth = strokeWidth,
)
// Draw lower top left.
drawLine(
color = color,
start = Offset(strokeOffset, strokeOffset),
end = Offset(strokeOffset, sideLength),
strokeWidth = strokeWidth,
)
// Draw upper top right.
drawLine(
color = color,
start = Offset(squareSize - sideLength, strokeOffset),
end = Offset(squareSize - strokeOffset, strokeOffset),
strokeWidth = strokeWidth,
)
// Draw lower top right.
drawLine(
color = color,
start = Offset(squareSize - strokeOffset, 0f),
end = Offset(squareSize - strokeOffset, sideLength),
strokeWidth = strokeWidth,
)
// Draw upper bottom right.
drawLine(
color = color,
start = Offset(squareSize - strokeOffset, squareSize),
end = Offset(squareSize - strokeOffset, squareSize - sideLength),
strokeWidth = strokeWidth,
)
// Draw lower bottom right.
drawLine(
color = color,
start = Offset(squareSize - strokeOffset, squareSize - strokeOffset),
end = Offset(squareSize - sideLength, squareSize - strokeOffset),
strokeWidth = strokeWidth,
)
// Draw upper bottom left.
drawLine(
color = color,
start = Offset(strokeOffset, squareSize),
end = Offset(strokeOffset, squareSize - sideLength),
strokeWidth = strokeWidth,
)
// Draw lower bottom left.
drawLine(
color = color,
start = Offset(0f, squareSize - strokeOffset),
end = Offset(sideLength, squareSize - strokeOffset),
strokeWidth = strokeWidth,
)
}
}
}
}
}

View file

@ -0,0 +1,101 @@
package com.x8bit.bitwarden.ui.vault.feature.qrcodescan
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
/**
* Handles [QrCodeScanAction],
* and launches [QrCodeScanEvent] for the [QrCodeScanScreen].
*/
@HiltViewModel
class QrCodeScanViewModel @Inject constructor(
private val vaultRepository: VaultRepository,
) : BaseViewModel<Unit, QrCodeScanEvent, QrCodeScanAction>(
initialState = Unit,
) {
override fun handleAction(action: QrCodeScanAction) {
when (action) {
is QrCodeScanAction.CloseClick -> handleCloseClick()
is QrCodeScanAction.ManualEntryTextClick -> handleManualEntryTextClick()
is QrCodeScanAction.QrCodeScanReceive -> handleQrCodeScanReceive(action)
is QrCodeScanAction.CameraSetupErrorReceive -> handleCameraErrorReceive(action)
}
}
private fun handleCloseClick() {
sendEvent(
QrCodeScanEvent.NavigateBack,
)
}
private fun handleManualEntryTextClick() {
// TODO: Implement Manual Entry Screen (BIT-1114)
sendEvent(
QrCodeScanEvent.ShowToast(
message = "Not yet implemented.".asText(),
),
)
}
private fun handleQrCodeScanReceive(action: QrCodeScanAction.QrCodeScanReceive) {
vaultRepository.emitTotpCode(action.qrCode)
sendEvent(QrCodeScanEvent.NavigateBack)
}
private fun handleCameraErrorReceive(
action: QrCodeScanAction.CameraSetupErrorReceive,
) {
// TODO: Implement Manual Entry Screen (BIT-1114)
sendEvent(
QrCodeScanEvent.ShowToast(
message = "Not yet implemented.".asText(),
),
)
}
}
/**
* Models events for the [QrCodeScanScreen].
*/
sealed class QrCodeScanEvent {
/**
* Navigate back.
*/
data object NavigateBack : QrCodeScanEvent()
/**
* Show a toast with the given [message].
*/
data class ShowToast(val message: Text) : QrCodeScanEvent()
}
/**
* Models actions for the [QrCodeScanScreen].
*/
sealed class QrCodeScanAction {
/**
* User clicked close.
*/
data object CloseClick : QrCodeScanAction()
/**
* The user has scanned a QR code.
*/
data class QrCodeScanReceive(val qrCode: String) : QrCodeScanAction()
/**
* The text to switch to manual entry is clicked.
*/
data object ManualEntryTextClick : QrCodeScanAction()
/**
* The Camera is unable to be setup.
*/
data object CameraSetupErrorReceive : QrCodeScanAction()
}

View file

@ -0,0 +1,16 @@
package com.x8bit.bitwarden.ui.vault.feature.qrcodescan.util
import androidx.camera.core.ImageAnalysis
import androidx.compose.runtime.Stable
/**
* An interface that is used to help scan QR codes.
*/
@Stable
interface QrCodeAnalyzer : ImageAnalysis.Analyzer {
/**
* The method that is called once the code is scanned.
*/
var onQrCodeScanned: (String) -> Unit
}

View file

@ -0,0 +1,72 @@
package com.x8bit.bitwarden.ui.vault.feature.qrcodescan.util
import androidx.camera.core.ImageProxy
import com.google.zxing.BarcodeFormat
import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType
import com.google.zxing.MultiFormatReader
import com.google.zxing.NotFoundException
import com.google.zxing.PlanarYUVLuminanceSource
import com.google.zxing.common.HybridBinarizer
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import java.nio.ByteBuffer
/**
* A class setup to handle image analysis so that we can use the Zxing library
* to scan QR codes and convert them to a string.
*/
@OmitFromCoverage
class QrCodeAnalyzerImpl : QrCodeAnalyzer {
/**
* This will ensure the result is only sent once as multiple images with a valid
* QR code can be sent for analysis.
*/
private var qrCodeRead = false
override lateinit var onQrCodeScanned: (String) -> Unit
override fun analyze(image: ImageProxy) {
if (qrCodeRead) {
return
}
val source = PlanarYUVLuminanceSource(
image.planes[0].buffer.toByteArray(),
image.width,
image.height,
0,
0,
image.width,
image.height,
false,
)
val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
try {
val result = MultiFormatReader()
.apply {
setHints(
mapOf(
DecodeHintType.POSSIBLE_FORMATS to arrayListOf(
BarcodeFormat.QR_CODE,
),
),
)
}
.decode(binaryBitmap)
qrCodeRead = true
onQrCodeScanned(result.text)
} catch (e: NotFoundException) {
return
} finally {
image.close()
}
}
}
/**
* This function helps us prepare the byte buffer to be read.
*/
private fun ByteBuffer.toByteArray(): ByteArray =
ByteArray(rewind().remaining()).also { get(it) }

View file

@ -115,8 +115,7 @@ private fun VaultAddItemState.ViewState.Content.ItemType.toLoginView(
match = UriMatchType.DOMAIN,
),
),
// TODO Implement TOTP (BIT-1066)
totp = common.originalCipher?.login?.totp,
totp = it.totp,
autofillOnPageLoad = common.originalCipher?.login?.autofillOnPageLoad,
)
}

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="18dp"
android:viewportWidth="20"
android:viewportHeight="18">
<path
android:pathData="M3.125,5.25C2.78,5.25 2.5,5.53 2.5,5.875C2.5,6.22 2.78,6.5 3.125,6.5H4.375C4.72,6.5 5,6.22 5,5.875C5,5.53 4.72,5.25 4.375,5.25H3.125Z"
android:fillColor="#000000"/>
<path
android:pathData="M14.219,0.25H7.969C6.588,0.25 5.469,1.369 5.469,2.75H2.5C1.119,2.75 0,3.869 0,5.25V14C0,15.381 1.119,16.5 2.5,16.5H7.396C8.479,17.286 9.81,17.75 11.25,17.75C12.69,17.75 14.021,17.286 15.104,16.5H17.5C18.881,16.5 20,15.381 20,14V5.25C20,3.869 18.881,2.75 17.5,2.75H16.719C16.719,1.369 15.599,0.25 14.219,0.25ZM4.688,11.188C4.688,12.722 5.214,14.133 6.096,15.25H2.5C1.81,15.25 1.25,14.69 1.25,14V5.25C1.25,4.56 1.81,4 2.5,4H5.469C6.159,4 6.719,3.44 6.719,2.75C6.719,2.06 7.278,1.5 7.969,1.5H14.219C14.909,1.5 15.469,2.06 15.469,2.75C15.469,3.44 16.028,4 16.719,4H17.5C18.19,4 18.75,4.56 18.75,5.25V14C18.75,14.69 18.19,15.25 17.5,15.25H16.404C16.376,15.286 16.347,15.322 16.318,15.357C17.252,14.224 17.813,12.771 17.813,11.188C17.813,7.563 14.874,4.625 11.25,4.625C7.626,4.625 4.688,7.563 4.688,11.188ZM11.25,16.5C14.184,16.5 16.563,14.122 16.563,11.188C16.563,8.253 14.184,5.875 11.25,5.875C8.316,5.875 5.938,8.253 5.938,11.188C5.938,14.122 8.316,16.5 11.25,16.5Z"
android:fillColor="#000000"
android:fillType="evenOdd"/>
</vector>

View file

@ -110,4 +110,5 @@
<color name="dark_password_strength_weak">@color/orange_C9914F</color>
<color name="light_password_strength_strong">@color/green_017E45</color>
<color name="dark_password_strength_strong">@color/green_41B06D</color>
<color name="qr_code_clickable_text">@color/blue_B2C5FF</color>
</resources>

View file

@ -1,11 +1,13 @@
package com.x8bit.bitwarden.ui.vault.feature.additem
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.click
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
@ -24,16 +26,19 @@ import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTouchInput
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.FakePermissionManager
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString
import com.x8bit.bitwarden.ui.util.isProgressBar
import com.x8bit.bitwarden.ui.util.onAllNodesWithTextAfterScroll
import com.x8bit.bitwarden.ui.util.onNodeWithContentDescriptionAfterScroll
import com.x8bit.bitwarden.ui.util.onNodeWithTextAfterScroll
import com.x8bit.bitwarden.ui.vault.feature.additem.model.CustomFieldType
import com.x8bit.bitwarden.ui.platform.base.util.FakePermissionManager
import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@ -46,6 +51,9 @@ import org.junit.Test
class VaultAddItemScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private var onNavigateQrCodeScanScreenCalled = false
private val clipboardManager = mockk<ClipboardManager>()
private val mutableEventFlow = MutableSharedFlow<VaultAddItemEvent>(Int.MAX_VALUE)
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE_LOGIN)
@ -64,6 +72,10 @@ class VaultAddItemScreenTest : BaseComposeTest() {
viewModel = viewModel,
onNavigateBack = { onNavigateBackCalled = true },
permissionsManager = fakePermissionManager,
clipboardManager = clipboardManager,
onNavigateToQrCodeScanScreen = {
onNavigateQrCodeScanScreenCalled = true
},
)
}
}
@ -74,6 +86,26 @@ class VaultAddItemScreenTest : BaseComposeTest() {
assertTrue(onNavigateBackCalled)
}
@Suppress("MaxLineLength")
@Test
fun `on NavigateToQrCodeScan event should invoke NavigateToQrCodeScan`() {
mutableEventFlow.tryEmit(VaultAddItemEvent.NavigateToQrCodeScan)
assertTrue(onNavigateQrCodeScanScreenCalled)
}
@Test
fun `on CopyToClipboard should call setText on ClipboardManager`() {
val textString = "text"
every { clipboardManager.setText(textString.toAnnotatedString()) } just runs
mutableEventFlow.tryEmit(VaultAddItemEvent.CopyToClipboard(textString))
verify(exactly = 1) {
clipboardManager.setText(textString.toAnnotatedString())
}
}
@Test
fun `clicking close button should send CloseClick action`() {
composeTestRule
@ -207,12 +239,14 @@ class VaultAddItemScreenTest : BaseComposeTest() {
.onNodeWithContentDescriptionAfterScroll(label = "Type, Login")
.assertIsDisplayed()
mutableStateFlow.update { it.copy(
viewState = VaultAddItemState.ViewState.Content(
common = VaultAddItemState.ViewState.Content.Common(),
type = VaultAddItemState.ViewState.Content.ItemType.Card,
),
) }
mutableStateFlow.update {
it.copy(
viewState = VaultAddItemState.ViewState.Content(
common = VaultAddItemState.ViewState.Content.Common(),
type = VaultAddItemState.ViewState.Content.ItemType.Card,
),
)
}
composeTestRule
.onNodeWithContentDescriptionAfterScroll(label = "Type, Card")
@ -319,6 +353,97 @@ class VaultAddItemScreenTest : BaseComposeTest() {
.assertTextContains("•••••••••••")
}
@Suppress("MaxLineLength")
@Test
fun `in ItemType_Login state the totp text field should be present based on state`() {
mutableStateFlow.update { currentState ->
updateLoginType(currentState) { copy(totp = "TestCode") }
}
composeTestRule
.onNodeWithTextAfterScroll("TOTP")
.assertTextEquals("TOTP", "TestCode")
mutableStateFlow.update { currentState ->
updateLoginType(currentState) { copy(totp = "NewTestCode") }
}
composeTestRule
.onNodeWithTextAfterScroll("TOTP")
.assertTextEquals("TOTP", "NewTestCode")
mutableStateFlow.update { currentState ->
updateLoginType(currentState) { copy(totp = null) }
}
composeTestRule
.onNodeWithText("TOTP")
.assertDoesNotExist()
}
@Suppress("MaxLineLength")
@Test
fun `in ItemType_Login state clicking the copy totp code button should trigger CopyTotpKeyClick`() {
val testCode = "TestCode"
mutableStateFlow.update { currentState ->
updateLoginType(currentState) { copy(totp = testCode) }
}
composeTestRule
.onNodeWithContentDescriptionAfterScroll("Copy TOTP")
.performClick()
verify {
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.CopyTotpKeyClick(testCode),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `in ItemType_Login state clicking the camera totp code button should trigger SetupTotpClick with result`() {
fakePermissionManager.checkPermissionResult = false
fakePermissionManager.getPermissionsResult = true
val testCode = "TestCode"
mutableStateFlow.update { currentState ->
updateLoginType(currentState) { copy(totp = testCode) }
}
composeTestRule
.onNodeWithContentDescriptionAfterScroll("Camera")
.performClick()
verify {
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.SetupTotpClick(
isGranted = fakePermissionManager.getPermissionsResult,
),
)
}
}
@Test
fun `in ItemType_Login state SetupTOTP button should be present based on state`() {
mutableStateFlow.update { currentState ->
updateLoginType(currentState) { copy(totp = null) }
}
composeTestRule
.onNodeWithTextAfterScroll(text = "Set up TOTP")
.assertIsDisplayed()
mutableStateFlow.update { currentState ->
updateLoginType(currentState) { copy(totp = "TestCode") }
}
composeTestRule
.onNodeWithText(text = "Set up TOTP")
.assertIsNotDisplayed()
}
@Suppress("MaxLineLength")
@Test
fun `in ItemType_Login state clicking SetupTOTP button with a positive result should send true if permission check returns true`() {
@ -347,7 +472,9 @@ class VaultAddItemScreenTest : BaseComposeTest() {
verify {
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.SetupTotpClick(true),
VaultAddItemAction.ItemType.LoginType.SetupTotpClick(
isGranted = true,
),
)
}
}
@ -364,7 +491,9 @@ class VaultAddItemScreenTest : BaseComposeTest() {
verify {
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.SetupTotpClick(false),
VaultAddItemAction.ItemType.LoginType.SetupTotpClick(
isGranted = false,
),
)
}
}
@ -1501,7 +1630,7 @@ class VaultAddItemScreenTest : BaseComposeTest() {
private fun updateLoginType(
currentState: VaultAddItemState,
transform: VaultAddItemState.ViewState.Content.ItemType.Login.() ->
VaultAddItemState.ViewState.Content.ItemType.Login,
VaultAddItemState.ViewState.Content.ItemType.Login,
): VaultAddItemState {
val updatedType = when (val viewState = currentState.viewState) {
is VaultAddItemState.ViewState.Content -> {
@ -1511,9 +1640,11 @@ class VaultAddItemScreenTest : BaseComposeTest() {
type = type.transform(),
)
}
else -> viewState
}
}
else -> viewState
}
return currentState.copy(viewState = updatedType)
@ -1532,9 +1663,11 @@ class VaultAddItemScreenTest : BaseComposeTest() {
type = type.transform(),
)
}
else -> viewState
}
}
else -> viewState
}
return currentState.copy(viewState = updatedType)
@ -1549,6 +1682,7 @@ class VaultAddItemScreenTest : BaseComposeTest() {
val updatedType = when (val viewState = currentState.viewState) {
is VaultAddItemState.ViewState.Content ->
viewState.copy(common = viewState.common.transform())
else -> viewState
}
return currentState.copy(viewState = updatedType)
@ -1591,7 +1725,11 @@ class VaultAddItemScreenTest : BaseComposeTest() {
customFieldData = listOf(
VaultAddItemState.Custom.BooleanField("Test ID", "TestBoolean", false),
VaultAddItemState.Custom.TextField("Test ID", "TestText", "TestTextVal"),
VaultAddItemState.Custom.HiddenField("Test ID", "TestHidden", "TestHiddenVal"),
VaultAddItemState.Custom.HiddenField(
"Test ID",
"TestHidden",
"TestHiddenVal",
),
),
),
type = VaultAddItemState.ViewState.Content.ItemType.SecureNotes,

View file

@ -23,6 +23,7 @@ import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
@ -41,9 +42,15 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
state = loginInitialState,
vaultAddEditType = VaultAddEditType.AddItem,
)
private val totpTestCodeFlow: MutableSharedFlow<String> = MutableSharedFlow(
extraBufferCapacity = Int.MAX_VALUE,
)
private val mutableVaultItemFlow = MutableStateFlow<DataState<CipherView?>>(DataState.Loading)
private val vaultRepository: VaultRepository = mockk {
every { getVaultItemStateFlow(DEFAULT_EDIT_ITEM_ID) } returns mutableVaultItemFlow
every { totpCodeFlow } returns totpTestCodeFlow
}
@BeforeEach
@ -208,7 +215,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
} returns CreateCipherResult.Error
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VaultAddItemAction.Common.SaveClick)
assertEquals(VaultAddItemEvent.ShowToast("Save Item Failure"), awaitItem())
assertEquals(VaultAddItemEvent.ShowToast("Save Item Failure".asText()), awaitItem())
}
}
@ -286,7 +293,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VaultAddItemAction.Common.SaveClick)
assertEquals(VaultAddItemEvent.ShowToast("Save Item Failure"), awaitItem())
assertEquals(VaultAddItemEvent.ShowToast("Save Item Failure".asText()), awaitItem())
}
coVerify(exactly = 1) {
@ -434,7 +441,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
VaultAddItemAction.ItemType.LoginType.OpenUsernameGeneratorClick,
)
assertEquals(
VaultAddItemEvent.ShowToast("Open Username Generator"),
VaultAddItemEvent.ShowToast("Open Username Generator".asText()),
awaitItem(),
)
}
@ -450,7 +457,12 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
.actionChannel
.trySend(VaultAddItemAction.ItemType.LoginType.PasswordCheckerClick)
assertEquals(VaultAddItemEvent.ShowToast("Password Checker"), awaitItem())
assertEquals(
VaultAddItemEvent.ShowToast(
"Password Checker".asText(),
),
awaitItem(),
)
}
}
@ -466,7 +478,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
.trySend(VaultAddItemAction.ItemType.LoginType.OpenPasswordGeneratorClick)
assertEquals(
VaultAddItemEvent.ShowToast("Open Password Generator"),
VaultAddItemEvent.ShowToast("Open Password Generator".asText()),
awaitItem(),
)
}
@ -474,17 +486,57 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength")
@Test
fun `SetupTotpClick should emit ShowToast with permission granted when isGranted is true`() = runTest {
fun `SetupTotpClick should emit NavigateToQrCodeScan when isGranted is true`() =
runTest {
val viewModel = createAddVaultItemViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(
VaultAddItemAction.ItemType.LoginType.SetupTotpClick(
isGranted = true,
),
)
assertEquals(
VaultAddItemEvent.NavigateToQrCodeScan,
awaitItem(),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `SetupTotpClick should emit ShowToast with permission not granted when isGranted is false`() =
runTest {
val viewModel = createAddVaultItemViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(
VaultAddItemAction.ItemType.LoginType.SetupTotpClick(
isGranted = false,
),
)
assertEquals(
VaultAddItemEvent.ShowToast("Permission Not Granted, Manual QR Code Entry Not Implemented".asText()),
awaitItem(),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `CopyTotpKeyClick should emit a toast and CopyToClipboard`() = runTest {
val viewModel = createAddVaultItemViewModel()
val testKey = "TestKey"
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(
VaultAddItemAction.ItemType.LoginType.SetupTotpClick(
true,
VaultAddItemAction.ItemType.LoginType.CopyTotpKeyClick(
testKey,
),
)
assertEquals(
VaultAddItemEvent.ShowToast("Permission Granted, QR Code Scanner Not Implemented"),
VaultAddItemEvent.CopyToClipboard(testKey),
awaitItem(),
)
}
@ -492,19 +544,35 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength")
@Test
fun `SetupTotpClick should emit ShowToast with permission not granted when isGranted is false`() = runTest {
fun `TotpCodeReceive should update totp code in state`() = runTest {
val viewModel = createAddVaultItemViewModel()
val testKey = "TestKey"
val expectedState = loginInitialState.copy(
viewState = VaultAddItemState.ViewState.Content(
common = createCommonContentViewState(),
type = createLoginTypeContentViewState(
totpCode = testKey,
),
),
)
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(
VaultAddItemAction.ItemType.LoginType.SetupTotpClick(
false,
VaultAddItemAction.Internal.TotpCodeReceive(
testKey,
),
)
assertEquals(
VaultAddItemEvent.ShowToast("Permission Not Granted, Manual QR Code Entry Not Implemented"),
VaultAddItemEvent.ShowToast(R.string.authenticator_key_added.asText()),
awaitItem(),
)
assertEquals(
expectedState,
viewModel.stateFlow.value,
)
}
}
@ -515,7 +583,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VaultAddItemAction.ItemType.LoginType.UriSettingsClick)
assertEquals(VaultAddItemEvent.ShowToast("URI Settings"), awaitItem())
assertEquals(VaultAddItemEvent.ShowToast("URI Settings".asText()), awaitItem())
}
}
@ -530,7 +598,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
VaultAddItemAction.ItemType.LoginType.AddNewUriClick,
)
assertEquals(VaultAddItemEvent.ShowToast("Add New URI"), awaitItem())
assertEquals(VaultAddItemEvent.ShowToast("Add New URI".asText()), awaitItem())
}
}
}
@ -967,7 +1035,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
fun `AddNewCustomFieldClick should allow a user to add a custom boolean field in Secure notes item`() =
runTest {
assertAddNewCustomFieldClick(
initialState = vaultAddItemInitialState,
initialState = vaultAddItemInitialState,
type = CustomFieldType.BOOLEAN,
)
}
@ -1075,7 +1143,12 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
.trySend(
VaultAddItemAction.Common.TooltipClick,
)
assertEquals(VaultAddItemEvent.ShowToast("Not yet implemented"), awaitItem())
assertEquals(
VaultAddItemEvent.ShowToast(
"Not yet implemented".asText(),
),
awaitItem(),
)
}
}
}
@ -1122,11 +1195,13 @@ class VaultAddItemViewModelTest : BaseViewModelTest() {
username: String = "",
password: String = "",
uri: String = "",
totpCode: String? = null,
): VaultAddItemState.ViewState.Content.ItemType.Login =
VaultAddItemState.ViewState.Content.ItemType.Login(
username = username,
password = password,
uri = uri,
totp = totpCode,
)
private fun createSavedStateHandleWithState(

View file

@ -119,6 +119,7 @@ class CipherViewExtensionsTest {
)
}
@Suppress("MaxLineLength")
@Test
fun `toViewState should create a Login ViewState`() {
val cipherView = DEFAULT_LOGIN_CIPHER_VIEW
@ -152,6 +153,7 @@ class CipherViewExtensionsTest {
username = "username",
password = "password",
uri = "www.example.com",
totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example",
),
),
result,

View file

@ -0,0 +1,91 @@
package com.x8bit.bitwarden.ui.vault.feature.qrcodescan
import androidx.camera.core.ImageProxy
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.vault.feature.qrcodescan.util.FakeQrCodeAnalyzer
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
class QrCodeScanScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private val imageProxy: ImageProxy = mockk()
private val qrCodeAnalyzer = FakeQrCodeAnalyzer()
private val mutableEventFlow = MutableSharedFlow<QrCodeScanEvent>(
extraBufferCapacity = Int.MAX_VALUE,
)
private val viewModel = mockk<QrCodeScanViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
}
@Before
fun setup() {
composeTestRule.setContent {
QrCodeScanScreen(
qrCodeAnalyzer = qrCodeAnalyzer,
viewModel = viewModel,
onNavigateBack = { onNavigateBackCalled = true },
)
}
}
@Test
fun `on NavigateBack event should invoke onNavigateBack`() {
mutableEventFlow.tryEmit(QrCodeScanEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `clicking on manual text should send ManualEntryTextClick`() = runTest {
composeTestRule
.onNodeWithText("Enter key manually")
.performClick()
verify {
viewModel.trySendAction(QrCodeScanAction.ManualEntryTextClick)
}
}
@Test
fun `when unable to setup camera CameraErrorReceive will be sent`() = runTest {
// Because the camera is not set up in the tests, this will always be triggered
verify {
viewModel.trySendAction(QrCodeScanAction.CameraSetupErrorReceive)
}
}
@Test
fun `when a scan is successful a result will be sent`() = runTest {
val result = "testCode"
qrCodeAnalyzer.scanResult = result
qrCodeAnalyzer.analyze(imageProxy)
verify {
viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(result))
}
}
@Test
fun `when a scan is unsuccessful a result will not be sent`() = runTest {
val result = "testCode"
qrCodeAnalyzer.scanResult = null
qrCodeAnalyzer.analyze(imageProxy)
verify(exactly = 0) {
viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(result))
}
}
}

View file

@ -0,0 +1,81 @@
package com.x8bit.bitwarden.ui.vault.feature.qrcodescan
import app.cash.turbine.test
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class QrCodeScanViewModelTest : BaseViewModelTest() {
private val totpTestCodeFlow: Flow<String> = MutableSharedFlow(
extraBufferCapacity = Int.MAX_VALUE,
)
private val vaultRepository: VaultRepository = mockk {
every { totpCodeFlow } returns totpTestCodeFlow
every { emitTotpCode(any()) } just runs
}
@Test
fun `CloseClick should emit NavigateBack`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(QrCodeScanAction.CloseClick)
assertEquals(QrCodeScanEvent.NavigateBack, awaitItem())
}
}
@Test
fun `CameraErrorReceive should emit ShowToast`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(QrCodeScanAction.CameraSetupErrorReceive)
assertEquals(
QrCodeScanEvent.ShowToast("Not yet implemented.".asText()),
awaitItem(),
)
}
}
@Test
fun `ManualEntryTextClick should emit ShowToast`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(QrCodeScanAction.ManualEntryTextClick)
assertEquals(
QrCodeScanEvent.ShowToast("Not yet implemented.".asText()),
awaitItem(),
)
}
}
@Test
fun `QrCodeScan should emit new code and NavigateBack`() = runTest {
val viewModel = createViewModel()
val code = "NewCode"
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(QrCodeScanAction.QrCodeScanReceive(code))
verify(exactly = 1) { vaultRepository.emitTotpCode(code) }
assertEquals(QrCodeScanEvent.NavigateBack, awaitItem())
}
}
fun createViewModel(): QrCodeScanViewModel =
QrCodeScanViewModel(
vaultRepository = vaultRepository,
)
}

View file

@ -0,0 +1,21 @@
package com.x8bit.bitwarden.ui.vault.feature.qrcodescan.util
import androidx.camera.core.ImageProxy
/**
* A helper class that helps test scan outcomes.
*/
class FakeQrCodeAnalyzer : QrCodeAnalyzer {
override lateinit var onQrCodeScanned: (String) -> Unit
/**
* The result of the scan that will be sent to the ViewModel (or `null` to indicate a
* scanning error.
*/
var scanResult: String? = null
override fun analyze(image: ImageProxy) {
scanResult?.let { onQrCodeScanned.invoke(it) }
}
}

View file

@ -31,10 +31,12 @@ class VaultAddItemStateExtensionsTest {
unmockkStatic(Instant::class)
}
@Suppress("MaxLineLength")
@Test
fun `toCipherView should transform Login ItemType to CipherView`() {
mockkStatic(Instant::class)
every { Instant.now() } returns Instant.MIN
val loginItemType = VaultAddItemState.ViewState.Content(
common = VaultAddItemState.ViewState.Content.Common(
name = "mockName-1",
@ -48,6 +50,7 @@ class VaultAddItemStateExtensionsTest {
username = "mockUsername-1",
password = "mockPassword-1",
uri = "mockUri-1",
totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example",
),
)
@ -73,7 +76,7 @@ class VaultAddItemStateExtensionsTest {
match = UriMatchType.DOMAIN,
),
),
totp = null,
totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example",
autofillOnPageLoad = null,
),
identity = null,
@ -96,6 +99,7 @@ class VaultAddItemStateExtensionsTest {
)
}
@Suppress("MaxLineLength")
@Test
fun `toCipherView should transform Login ItemType to CipherView with original cipher`() {
val cipherView = DEFAULT_LOGIN_CIPHER_VIEW
@ -123,6 +127,7 @@ class VaultAddItemStateExtensionsTest {
username = "mockUsername-1",
password = "mockPassword-1",
uri = "mockUri-1",
totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example",
),
)

View file

@ -11,6 +11,7 @@ accompanist = "0.30.1"
androidGradlePlugin = "8.2.0"
androidxActivity = "1.8.2"
androidxBrowser = "1.7.0"
androidxCamera = "1.3.1"
androidxComposeBom = "2023.10.01"
# TODO: Once the Material3 color scheme changes are no longer in alpha, we should remove this
# individual dependency version and use the Compose BOM version (BIT-702).
@ -52,6 +53,9 @@ zxing = "3.5.2"
# Format: <maintainer>-<artifact-name>
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" }
androidx-browser = { module = "androidx.browser:browser", version.ref = "androidxBrowser" }
androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidxCamera" }
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "androidxCamera" }
androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "androidxCamera" }
androidx-compose-animation = { module = "androidx.compose.animation:animation" }
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidxComposeBom" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "androidxComposeMaterial3" }