diff --git a/README.md b/README.md index ed78dc1548..4835ca5b67 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index eb24c003b2..0c86ce506e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt index 560d9734b4..6ba33fbbff 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt @@ -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> + /** + * Flow that represents the totp code. + */ + val totpCodeFlow: Flow + /** * 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. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt index a7e8bdefaf..e0282c85e5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt @@ -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() + private val mutableVaultStateStateFlow = MutableStateFlow(VaultState(unlockedVaultUserIds = emptySet())) @@ -122,6 +126,9 @@ class VaultRepositoryImpl( initialValue = DataState.Loading, ) + override val totpCodeFlow: Flow + get() = mutableTotpCodeFlow.asSharedFlow() + override val ciphersStateFlow: StateFlow>> 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, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt index 6a60e56759..3eeee9c953 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt @@ -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() }) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt index 876f226105..a684480530 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt @@ -165,7 +165,12 @@ val LocalNonMaterialColors: ProvidableCompositionLocal = 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), ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/AddEditLoginItems.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/AddEditLoginItems.kt index ab78194b52..990152309b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/AddEditLoginItems.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/AddEditLoginItems.kt @@ -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 { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddEditItemNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddEditItemNavigation.kt index a3681c561f..7ebd7633ff 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddEditItemNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddEditItemNavigation.kt @@ -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) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemScreen.kt index 6715bd25ed..c4003da5e6 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemScreen.kt @@ -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() diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModel.kt index b2ae06425a..0f87b5bbfa 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModel.kt @@ -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. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/handlers/VaultAddLoginItemTypeHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/handlers/VaultAddLoginItemTypeHandlers.kt index c8b2e05c6f..cdedae9a34 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/handlers/VaultAddLoginItemTypeHandlers.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/handlers/VaultAddLoginItemTypeHandlers.kt @@ -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, + ), + ) + }, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/util/CipherViewExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/util/CipherViewExtensions.kt index 920decbde0..4eb3e8f6db 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/util/CipherViewExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/util/CipherViewExtensions.kt @@ -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, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanNavigation.kt new file mode 100644 index 0000000000..3e7fa67b42 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanNavigation.kt @@ -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) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanScreen.kt new file mode 100644 index 0000000000..f374231940 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanScreen.kt @@ -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, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModel.kt new file mode 100644 index 0000000000..7610878adf --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModel.kt @@ -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( + 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() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/util/QrCodeAnalyzer.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/util/QrCodeAnalyzer.kt new file mode 100644 index 0000000000..a0ea3552cb --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/util/QrCodeAnalyzer.kt @@ -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 +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/util/QrCodeAnalyzerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/util/QrCodeAnalyzerImpl.kt new file mode 100644 index 0000000000..a8c2eb8278 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/util/QrCodeAnalyzerImpl.kt @@ -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) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensions.kt index 69674e8432..f80e2f876e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensions.kt @@ -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, ) } diff --git a/app/src/main/res/drawable/ic_camera.xml b/app/src/main/res/drawable/ic_camera.xml new file mode 100644 index 0000000000..c7af235783 --- /dev/null +++ b/app/src/main/res/drawable/ic_camera.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f20040cbf7..999b53c7fe 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -110,4 +110,5 @@ @color/orange_C9914F @color/green_017E45 @color/green_41B06D + @color/blue_B2C5FF diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemScreenTest.kt index 5827f80a82..371b34f737 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemScreenTest.kt @@ -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() private val mutableEventFlow = MutableSharedFlow(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, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModelTest.kt index 0a888032ed..3337c9b79c 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModelTest.kt @@ -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 = MutableSharedFlow( + extraBufferCapacity = Int.MAX_VALUE, + ) + private val mutableVaultItemFlow = MutableStateFlow>(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( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/util/CipherViewExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/util/CipherViewExtensionsTest.kt index 589f1166f7..04fb7631fe 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/util/CipherViewExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/util/CipherViewExtensionsTest.kt @@ -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, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanScreenTest.kt new file mode 100644 index 0000000000..327e305abe --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanScreenTest.kt @@ -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( + extraBufferCapacity = Int.MAX_VALUE, + ) + + private val viewModel = mockk(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)) + } + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModelTest.kt new file mode 100644 index 0000000000..b9b098d6b0 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/QrCodeScanViewModelTest.kt @@ -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 = 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, + ) +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/util/FakeQrCodeAnalyzer.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/util/FakeQrCodeAnalyzer.kt new file mode 100644 index 0000000000..27c6337f8a --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/qrcodescan/util/FakeQrCodeAnalyzer.kt @@ -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) } + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensionsTest.kt index 8826fd6ec7..b74861c06f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensionsTest.kt @@ -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", ), ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4dbd42d9da..ffedd82389 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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: - 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" }