mirror of
https://github.com/bitwarden/android.git
synced 2025-02-16 11:59:57 +03:00
BIT-1101, BIT-1066, BIT-1071, BIT-1072 Adding QR code scanning feature (#464)
This commit is contained in:
parent
1c8501b69b
commit
e929641159
28 changed files with 1212 additions and 62 deletions
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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() })
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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) }
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
13
app/src/main/res/drawable/ic_camera.xml
Normal file
13
app/src/main/res/drawable/ic_camera.xml
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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) }
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
@ -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" }
|
||||
|
|
Loading…
Add table
Reference in a new issue