From 5de6dc3473f970e7e56fdaca36c7925e95c1cfa5 Mon Sep 17 00:00:00 2001 From: Andrew Haisting <142518658+ahaisting-livefront@users.noreply.github.com> Date: Wed, 30 Oct 2024 10:02:03 -0500 Subject: [PATCH] BITAU-89 Show save location dialog on the QR scan screen (#258) --- .../SharedVerificationCodesStateExtensions.kt | 21 ++ .../qrcodescan/ChooseSaveLocationDialog.kt | 119 ++++++++ .../feature/qrcodescan/QrCodeScanScreen.kt | 38 ++- .../feature/qrcodescan/QrCodeScanViewModel.kt | 141 ++++++++- .../components/util/DialogExtensions.kt | 15 + app/src/main/res/values/strings.xml | 5 + ...redVerificationCodesStateExtensionsTest.kt | 21 ++ .../feature/qrcodescan/FakeQrCodeAnalyzer.kt | 22 ++ .../qrcodescan/QrCodeScanScreenTest.kt | 138 +++++++++ .../qrcodescan/QrCodeScanViewModelTest.kt | 274 ++++++++++++++++++ 10 files changed, 786 insertions(+), 8 deletions(-) create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/util/SharedVerificationCodesStateExtensions.kt create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/ChooseSaveLocationDialog.kt create mode 100644 app/src/test/java/com/bitwarden/authenticator/data/authenticator/repository/util/SharedVerificationCodesStateExtensionsTest.kt create mode 100644 app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/FakeQrCodeAnalyzer.kt create mode 100644 app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanScreenTest.kt create mode 100644 app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModelTest.kt diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/util/SharedVerificationCodesStateExtensions.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/util/SharedVerificationCodesStateExtensions.kt new file mode 100644 index 0000000000..39e393f13f --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/util/SharedVerificationCodesStateExtensions.kt @@ -0,0 +1,21 @@ +package com.bitwarden.authenticator.data.authenticator.repository.util + +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState + +/** + * Whether or not the user has enabled sync with Bitwarden and the two apps are successfully + * syncing. This is useful to know when to show certain sync UI and also when to support + * moving codes to Bitwarden. + */ +val SharedVerificationCodesState.isSyncWithBitwardenEnabled: Boolean + get() = when (this) { + SharedVerificationCodesState.AppNotInstalled, + SharedVerificationCodesState.Error, + SharedVerificationCodesState.FeatureNotEnabled, + SharedVerificationCodesState.Loading, + SharedVerificationCodesState.OsVersionNotSupported, + SharedVerificationCodesState.SyncNotEnabled, + -> false + + is SharedVerificationCodesState.Success -> true + } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/ChooseSaveLocationDialog.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/ChooseSaveLocationDialog.kt new file mode 100644 index 0000000000..3588677f50 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/ChooseSaveLocationDialog.kt @@ -0,0 +1,119 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeightIn +import androidx.compose.foundation.layout.requiredWidthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.components.button.BitwardenTextButton +import com.bitwarden.authenticator.ui.platform.components.toggle.BitwardenWideSwitch +import com.bitwarden.authenticator.ui.platform.components.util.maxDialogHeight +import com.bitwarden.authenticator.ui.platform.components.util.maxDialogWidth + +/** + * Displays a dialog asking the user where they would like to save a new QR code. + * + * @param onSaveHereClick Invoked when the user clicks "Save here". The boolean parameter is true if + * the user check "Save option as default". + * @param onTakeMeToBitwardenClick Invoked when the user clicks "Take me to Bitwarden". The boolean + * parameter is true if the user checked "Save option as default". + */ +@Composable +@OptIn(ExperimentalLayoutApi::class) +@Suppress("LongMethod") +fun ChooseSaveLocationDialog( + onSaveHereClick: (Boolean) -> Unit, + onTakeMeToBitwardenClick: (Boolean) -> Unit, +) { + Dialog( + onDismissRequest = { }, // Not dismissible + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + var isSaveAsDefaultChecked by remember { mutableStateOf(false) } + val configuration = LocalConfiguration.current + Column( + modifier = Modifier + .requiredHeightIn( + max = configuration.maxDialogHeight, + ) + .requiredWidthIn( + max = configuration.maxDialogWidth, + ) + .background( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(28.dp), + ), + horizontalAlignment = Alignment.End, + ) { + Spacer(modifier = Modifier.height(24.dp)) + Text( + modifier = Modifier + .padding(horizontal = 24.dp) + .fillMaxWidth(), + text = stringResource(R.string.verification_code_created), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineSmall, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + modifier = Modifier + .weight(1f, fill = false) + .padding(horizontal = 24.dp) + .fillMaxWidth(), + text = stringResource(R.string.choose_save_location_message), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(16.dp)) + BitwardenWideSwitch( + modifier = Modifier.padding(horizontal = 16.dp), + label = stringResource(R.string.save_option_as_default), + isChecked = isSaveAsDefaultChecked, + onCheckedChange = { isSaveAsDefaultChecked = !isSaveAsDefaultChecked }, + ) + Spacer(Modifier.height(16.dp)) + FlowRow( + horizontalArrangement = Arrangement.End, + modifier = Modifier.padding(horizontal = 8.dp), + ) { + BitwardenTextButton( + modifier = Modifier + .padding(horizontal = 4.dp), + label = stringResource(R.string.save_here), + labelTextColor = MaterialTheme.colorScheme.primary, + onClick = { onSaveHereClick.invoke(isSaveAsDefaultChecked) }, + ) + BitwardenTextButton( + modifier = Modifier + .padding(horizontal = 4.dp), + label = stringResource(R.string.take_me_to_bitwarden), + labelTextColor = MaterialTheme.colorScheme.primary, + onClick = { onTakeMeToBitwardenClick.invoke(isSaveAsDefaultChecked) }, + ) + } + Spacer(modifier = Modifier.height(24.dp)) + } + } +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanScreen.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanScreen.kt index d422fdc0b9..e8988edf4a 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanScreen.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanScreen.kt @@ -56,11 +56,15 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitwarden.authenticator.R import com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan.util.QrCodeAnalyzer import com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan.util.QrCodeAnalyzerImpl import com.bitwarden.authenticator.ui.platform.base.util.EventsEffect +import com.bitwarden.authenticator.ui.platform.base.util.asText import com.bitwarden.authenticator.ui.platform.components.appbar.BitwardenTopAppBar +import com.bitwarden.authenticator.ui.platform.components.dialog.BasicDialogState +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenBasicDialog import com.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold import com.bitwarden.authenticator.ui.platform.theme.LocalNonMaterialColors import com.bitwarden.authenticator.ui.platform.theme.clickableSpanStyle @@ -83,9 +87,8 @@ fun QrCodeScanScreen( qrCodeAnalyzer.onQrCodeScanned = remember(viewModel) { { viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(it)) } } - + val state by viewModel.stateFlow.collectAsStateWithLifecycle() val orientation = LocalConfiguration.current.orientation - val context = LocalContext.current val onEnterCodeManuallyClick = remember(viewModel) { @@ -147,6 +150,37 @@ fun QrCodeScanScreen( ) } } + + when (state.dialog) { + QrCodeScanState.DialogState.ChooseSaveLocation -> { + ChooseSaveLocationDialog( + onSaveHereClick = remember(viewModel) { + { + viewModel.trySendAction(QrCodeScanAction.SaveLocallyClick(it)) + } + }, + onTakeMeToBitwardenClick = remember(viewModel) { + { + viewModel.trySendAction(QrCodeScanAction.SaveToBitwardenClick(it)) + } + }, + ) + } + + QrCodeScanState.DialogState.SaveToBitwardenError -> BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = R.string.something_went_wrong.asText(), + message = R.string.please_try_again.asText(), + ), + onDismissRequest = remember(viewModel) { + { + viewModel.trySendAction(QrCodeScanAction.SaveToBitwardenErrorDismiss) + } + }, + ) + + null -> Unit + } } } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModel.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModel.kt index 365bf73939..1ac07290d4 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModel.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModel.kt @@ -1,15 +1,22 @@ package com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan import android.net.Uri +import android.os.Parcelable import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.toUpperCase import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository import com.bitwarden.authenticator.data.authenticator.repository.model.TotpCodeResult +import com.bitwarden.authenticator.data.authenticator.repository.util.isSyncWithBitwardenEnabled +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository import com.bitwarden.authenticator.ui.platform.base.BaseViewModel import com.bitwarden.authenticator.ui.platform.base.util.Text import com.bitwarden.authenticator.ui.platform.base.util.isBase32 +import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption +import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize import javax.inject.Inject /** @@ -17,17 +24,56 @@ import javax.inject.Inject * and launches [QrCodeScanEvent] for the [QrCodeScanScreen]. */ @HiltViewModel +@Suppress("TooManyFunctions") class QrCodeScanViewModel @Inject constructor( + private val authenticatorBridgeManager: AuthenticatorBridgeManager, private val authenticatorRepository: AuthenticatorRepository, -) : BaseViewModel( - initialState = Unit, + private val settingsRepository: SettingsRepository, +) : BaseViewModel( + initialState = QrCodeScanState(dialog = null), ) { + + /** + * Keeps track of a pending successful scan to support the case where the user is choosing + * default save location. + */ + private var pendingSuccessfulScan: TotpCodeResult.TotpCodeScan? = null + override fun handleAction(action: QrCodeScanAction) { when (action) { is QrCodeScanAction.CloseClick -> handleCloseClick() is QrCodeScanAction.ManualEntryTextClick -> handleManualEntryTextClick() is QrCodeScanAction.CameraSetupErrorReceive -> handleCameraErrorReceive() is QrCodeScanAction.QrCodeScanReceive -> handleQrCodeScanReceive(action) + QrCodeScanAction.SaveToBitwardenErrorDismiss -> handleSaveToBitwardenDismiss() + is QrCodeScanAction.SaveLocallyClick -> handleSaveLocallyClick(action) + is QrCodeScanAction.SaveToBitwardenClick -> handleSaveToBitwardenClick(action) + } + } + + private fun handleSaveToBitwardenClick(action: QrCodeScanAction.SaveToBitwardenClick) { + if (action.saveAsDefault) { + settingsRepository.defaultSaveOption = DefaultSaveOption.BITWARDEN_APP + } + pendingSuccessfulScan?.let { + saveCodeToBitwardenAndNavigateBack(it) + } + pendingSuccessfulScan = null + } + + private fun handleSaveLocallyClick(action: QrCodeScanAction.SaveLocallyClick) { + if (action.saveAsDefault) { + settingsRepository.defaultSaveOption = DefaultSaveOption.LOCAL + } + pendingSuccessfulScan?.let { + saveCodeLocallyAndNavigateBack(it) + } + pendingSuccessfulScan = null + } + + private fun handleSaveToBitwardenDismiss() { + mutableStateFlow.update { + it.copy(dialog = null) } } @@ -58,7 +104,7 @@ class QrCodeScanViewModel @Inject constructor( // For more information: https://bitwarden.com/help/authenticator-keys/#support-for-more-parameters private fun handleTotpUriReceive(scannedCode: String) { - var result: TotpCodeResult = TotpCodeResult.TotpCodeScan(scannedCode) + val result = TotpCodeResult.TotpCodeScan(scannedCode) val scannedCodeUri = Uri.parse(scannedCode) val secretValue = scannedCodeUri .getQueryParameter(TotpCodeManager.SECRET_PARAM) @@ -72,11 +118,30 @@ class QrCodeScanViewModel @Inject constructor( } val values = scannedCodeUri.queryParameterNames + // If the parameters are not valid, if (!areParametersValid(scannedCode, values)) { - result = TotpCodeResult.CodeScanningError + authenticatorRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError) + sendEvent(QrCodeScanEvent.NavigateBack) + return + } + if (authenticatorRepository.sharedCodesStateFlow.value.isSyncWithBitwardenEnabled) { + when (settingsRepository.defaultSaveOption) { + DefaultSaveOption.BITWARDEN_APP -> saveCodeToBitwardenAndNavigateBack(result) + DefaultSaveOption.LOCAL -> saveCodeLocallyAndNavigateBack(result) + + DefaultSaveOption.NONE -> { + pendingSuccessfulScan = result + mutableStateFlow.update { + it.copy( + dialog = QrCodeScanState.DialogState.ChooseSaveLocation, + ) + } + } + } + } else { + // Syncing with Bitwarden not enabled, save code locally: + saveCodeLocallyAndNavigateBack(result) } - authenticatorRepository.emitTotpCodeResult(result) - sendEvent(QrCodeScanEvent.NavigateBack) } private fun handleGoogleExportUriReceive(scannedCode: String) { @@ -130,6 +195,51 @@ class QrCodeScanViewModel @Inject constructor( } return true } + + private fun saveCodeToBitwardenAndNavigateBack(result: TotpCodeResult.TotpCodeScan) { + val didLaunchAddToBitwarden = + authenticatorBridgeManager.startAddTotpLoginItemFlow(result.code) + if (didLaunchAddToBitwarden) { + sendEvent(QrCodeScanEvent.NavigateBack) + } else { + mutableStateFlow.update { + it.copy(dialog = QrCodeScanState.DialogState.SaveToBitwardenError) + } + } + } + + private fun saveCodeLocallyAndNavigateBack(result: TotpCodeResult.TotpCodeScan) { + authenticatorRepository.emitTotpCodeResult(result) + sendEvent(QrCodeScanEvent.NavigateBack) + } +} + +/** + * Models state for [QrCodeScanViewModel]. + * + * @param dialog Dialog to be shown, or `null` if no dialog should be shown. + */ +@Parcelize +data class QrCodeScanState( + val dialog: DialogState?, +) : Parcelable { + + /** + * Models dialogs that can be shown on the QR Scan screen. + */ + sealed class DialogState : Parcelable { + /** + * Displays a prompt to choose save location for a newly scanned code. + */ + @Parcelize + data object ChooseSaveLocation : DialogState() + + /** + * Displays an error letting the user know that saving to bitwarden failed. + */ + @Parcelize + data object SaveToBitwardenError : DialogState() + } } /** @@ -177,4 +287,23 @@ sealed class QrCodeScanAction { * The Camera is unable to be setup. */ data object CameraSetupErrorReceive : QrCodeScanAction() + + /** + * The user dismissed the Save to Bitwarden error dialog. + */ + data object SaveToBitwardenErrorDismiss : QrCodeScanAction() + + /** + * User clicked save to Bitwarden on the choose save location dialog. + * + * @param saveAsDefault Whether or not he user checked "Save as default". + */ + data class SaveToBitwardenClick(val saveAsDefault: Boolean) : QrCodeScanAction() + + /** + * User clicked save locally on the save to Bitwarden dialog. + * + * @param saveAsDefault Whether or not he user checked "Save as default". + */ + data class SaveLocallyClick(val saveAsDefault: Boolean) : QrCodeScanAction() } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/util/DialogExtensions.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/util/DialogExtensions.kt index 720ad54ec3..11da852ad2 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/util/DialogExtensions.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/util/DialogExtensions.kt @@ -18,3 +18,18 @@ val Configuration.maxDialogHeight: Dp else -> Dp.Unspecified } + +/** + * Provides the maximum width [Dp] common for all dialogs with a given [Configuration]. + */ +val Configuration.maxDialogWidth: Dp + get() = when (orientation) { + Configuration.ORIENTATION_LANDSCAPE -> 542.dp + Configuration.ORIENTATION_PORTRAIT -> 312.dp + Configuration.ORIENTATION_UNDEFINED -> Dp.Unspecified + @Suppress("DEPRECATION") + Configuration.ORIENTATION_SQUARE, + -> Dp.Unspecified + + else -> Dp.Unspecified + } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b3228297d6..c8ffeafa43 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -139,4 +139,9 @@ None Select where you would like to save new verification codes. Confirm + Save here + Take me to Bitwarden + Verification code created + Save this authenticator key here, or add it to a login in your Bitwarden app. + Save option as default diff --git a/app/src/test/java/com/bitwarden/authenticator/data/authenticator/repository/util/SharedVerificationCodesStateExtensionsTest.kt b/app/src/test/java/com/bitwarden/authenticator/data/authenticator/repository/util/SharedVerificationCodesStateExtensionsTest.kt new file mode 100644 index 0000000000..f3994b2aa2 --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/data/authenticator/repository/util/SharedVerificationCodesStateExtensionsTest.kt @@ -0,0 +1,21 @@ +package com.bitwarden.authenticator.data.authenticator.repository.util + +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SharedVerificationCodesStateExtensionsTest { + + @Test + @Suppress("MaxLineLength") + fun `isSyncWithBitwardenEnabled should return true only when SharedVerificationCodesState is Success `() { + assertFalse(SharedVerificationCodesState.AppNotInstalled.isSyncWithBitwardenEnabled) + assertFalse(SharedVerificationCodesState.Error.isSyncWithBitwardenEnabled) + assertFalse(SharedVerificationCodesState.FeatureNotEnabled.isSyncWithBitwardenEnabled) + assertFalse(SharedVerificationCodesState.Loading.isSyncWithBitwardenEnabled) + assertFalse(SharedVerificationCodesState.OsVersionNotSupported.isSyncWithBitwardenEnabled) + assertFalse(SharedVerificationCodesState.SyncNotEnabled.isSyncWithBitwardenEnabled) + assertTrue(SharedVerificationCodesState.Success(emptyList()).isSyncWithBitwardenEnabled) + } +} diff --git a/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/FakeQrCodeAnalyzer.kt b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/FakeQrCodeAnalyzer.kt new file mode 100644 index 0000000000..6b109a87f8 --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/FakeQrCodeAnalyzer.kt @@ -0,0 +1,22 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan + +import androidx.camera.core.ImageProxy +import com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan.util.QrCodeAnalyzer + +/** + * 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/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanScreenTest.kt b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanScreenTest.kt new file mode 100644 index 0000000000..c11296bff4 --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanScreenTest.kt @@ -0,0 +1,138 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan + +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow +import com.bitwarden.authenticator.ui.platform.base.BaseComposeTest +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.Before +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertTrue + +class QrCodeScanScreenTest : BaseComposeTest() { + + private var onNavigateBackCalled = false + private var onNavigateToManualCodeEntryScreenCalled = false + + private val qrCodeAnalyzer = FakeQrCodeAnalyzer() + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val mutableEventFlow = bufferedMutableSharedFlow() + + val viewModel: QrCodeScanViewModel = mockk { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns mutableEventFlow + every { trySendAction(any()) } just runs + } + + @Before + fun setup() { + composeTestRule.setContent { + QrCodeScanScreen( + viewModel = viewModel, + qrCodeAnalyzer = qrCodeAnalyzer, + onNavigateBack = { onNavigateBackCalled = true }, + onNavigateToManualCodeEntryScreen = { + onNavigateToManualCodeEntryScreenCalled = true + }, + ) + } + } + + @Test + fun `on NavigateBack event receive should call navigate back`() { + mutableEventFlow.tryEmit(QrCodeScanEvent.NavigateBack) + assertTrue(onNavigateBackCalled) + } + + @Test + fun `on Save here click should send SaveLocallyClick action`() { + mutableStateFlow.update { + DEFAULT_STATE.copy( + dialog = QrCodeScanState.DialogState.ChooseSaveLocation, + ) + } + composeTestRule + .onNodeWithText("Save here") + .assertIsDisplayed() + .assert(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(QrCodeScanAction.SaveLocallyClick(false)) } + + // Click again but with "Save as default" checked: + composeTestRule + .onNodeWithText("Save option as default") + .performClick() + composeTestRule + .onNodeWithText("Save here") + .assertIsDisplayed() + .assert(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(QrCodeScanAction.SaveLocallyClick(true)) } + } + + @Test + fun `on Save to Bitwarden click should send SaveToBitwardenClick action`() { + mutableStateFlow.update { + DEFAULT_STATE.copy( + dialog = QrCodeScanState.DialogState.ChooseSaveLocation, + ) + } + composeTestRule + .onNodeWithText("Take me to Bitwarden") + .assertIsDisplayed() + .assert(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(QrCodeScanAction.SaveToBitwardenClick(false)) } + + // Click again but with "Save as default" checked: + composeTestRule + .onNodeWithText("Save option as default") + .performClick() + composeTestRule + .onNodeWithText("Take me to Bitwarden") + .assertIsDisplayed() + .assert(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(QrCodeScanAction.SaveToBitwardenClick(true)) } + } + + @Test + fun `dismissing error dialog should send SaveToBitwardenErrorDismiss`() { + // Make sure dialog isn't showing: + composeTestRule + .onNodeWithText("Something went wrong") + .assertDoesNotExist() + + // Display dialog and click OK + mutableStateFlow.update { + DEFAULT_STATE.copy( + dialog = QrCodeScanState.DialogState.SaveToBitwardenError, + ) + } + composeTestRule + .onNodeWithText("Something went wrong") + .assertIsDisplayed() + .assert(hasAnyAncestor(isDialog())) + composeTestRule + .onNodeWithText("OK") + .assertIsDisplayed() + .assert(hasAnyAncestor(isDialog())) + .performClick() + + verify { viewModel.trySendAction(QrCodeScanAction.SaveToBitwardenErrorDismiss) } + } +} + +private val DEFAULT_STATE = QrCodeScanState( + dialog = null, +) diff --git a/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModelTest.kt b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModelTest.kt new file mode 100644 index 0000000000..dc868e0b1b --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModelTest.kt @@ -0,0 +1,274 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan + +import android.net.Uri +import app.cash.turbine.test +import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState +import com.bitwarden.authenticator.data.authenticator.repository.model.TotpCodeResult +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +import com.bitwarden.authenticator.ui.platform.base.BaseViewModelTest +import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption +import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class QrCodeScanViewModelTest : BaseViewModelTest() { + + private val authenticatorBridgeManager: AuthenticatorBridgeManager = mockk() + private val authenticatorRepository: AuthenticatorRepository = mockk { + every { + sharedCodesStateFlow + } returns MutableStateFlow(SharedVerificationCodesState.Success(emptyList())) + } + private val settingsRepository: SettingsRepository = mockk { + every { defaultSaveOption } returns DefaultSaveOption.NONE + } + + @BeforeEach + fun setup() { + mockkStatic(Uri::parse) + every { Uri.parse(VALID_TOTP_CODE) } returns VALID_TOTP_URI + } + + @AfterEach + fun teardown() { + unmockkStatic(Uri::parse) + } + + @Test + fun `on SaveToBitwardenClick receive without a pending QR scan should do nothing`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(QrCodeScanAction.SaveToBitwardenClick(false)) + } + } + + @Test + @Suppress("MaxLineLength") + fun `on SaveToBitwardenClick receive with pending QR scan but no save to default should launch save to Bitwarden flow`() = + runTest { + val viewModel = createViewModel() + every { + authenticatorBridgeManager.startAddTotpLoginItemFlow(VALID_TOTP_CODE) + } returns true + viewModel.eventFlow.test { + viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(VALID_TOTP_CODE)) + viewModel.trySendAction(QrCodeScanAction.SaveToBitwardenClick(false)) + assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) + } + verify { authenticatorBridgeManager.startAddTotpLoginItemFlow(VALID_TOTP_CODE) } + } + + @Test + @Suppress("MaxLineLength") + fun `on SaveToBitwardenClick receive with pending QR scan but startAddTotpLoginItemFlow fails should show error dialog`() = + runTest { + val viewModel = createViewModel() + every { + authenticatorBridgeManager.startAddTotpLoginItemFlow(VALID_TOTP_CODE) + } returns false + viewModel.eventFlow.test { + viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(VALID_TOTP_CODE)) + viewModel.trySendAction(QrCodeScanAction.SaveToBitwardenClick(false)) + } + val expectedState = + DEFAULT_STATE.copy(dialog = QrCodeScanState.DialogState.SaveToBitwardenError) + assertEquals(expectedState, viewModel.stateFlow.value) + verify { authenticatorBridgeManager.startAddTotpLoginItemFlow(VALID_TOTP_CODE) } + } + + @Test + @Suppress("MaxLineLength") + fun `on SaveToBitwardenClick receive with pending QR scan but and save to default should launch save to Bitwarden flow and update SettingsRepository`() = + runTest { + val viewModel = createViewModel() + every { + settingsRepository.defaultSaveOption = DefaultSaveOption.BITWARDEN_APP + } just runs + every { + authenticatorBridgeManager.startAddTotpLoginItemFlow(VALID_TOTP_CODE) + } returns true + viewModel.eventFlow.test { + viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(VALID_TOTP_CODE)) + viewModel.trySendAction(QrCodeScanAction.SaveToBitwardenClick(true)) + assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) + } + verify { authenticatorBridgeManager.startAddTotpLoginItemFlow(VALID_TOTP_CODE) } + verify { settingsRepository.defaultSaveOption = DefaultSaveOption.BITWARDEN_APP } + } + + @Test + fun `on SaveLocallyClick receive without a pending QR scan should do nothing`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(QrCodeScanAction.SaveLocallyClick(false)) + } + } + + @Test + @Suppress("MaxLineLength") + fun `on SaveLocallyClick receive with pending QR scan but no save to default should emit code to AuthenticatorRepository and navigate back`() = + runTest { + val viewModel = createViewModel() + every { + authenticatorRepository.emitTotpCodeResult(VALID_TOTP_RESULT) + } just runs + viewModel.eventFlow.test { + viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(VALID_TOTP_CODE)) + viewModel.trySendAction(QrCodeScanAction.SaveLocallyClick(false)) + assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) + } + verify { + authenticatorRepository.emitTotpCodeResult(VALID_TOTP_RESULT) + } + } + + @Test + @Suppress("MaxLineLength") + fun `on SaveLocallyClick receive with pending QR scan but and save to default should emit result to AuthenticatorRepository and update SettingsRepository`() = + runTest { + val viewModel = createViewModel() + every { + settingsRepository.defaultSaveOption = DefaultSaveOption.LOCAL + } just runs + every { + authenticatorRepository.emitTotpCodeResult( + TotpCodeResult.TotpCodeScan(VALID_TOTP_CODE), + ) + } just runs + viewModel.eventFlow.test { + viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(VALID_TOTP_CODE)) + viewModel.trySendAction(QrCodeScanAction.SaveLocallyClick(true)) + assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) + } + verify { + authenticatorRepository.emitTotpCodeResult( + TotpCodeResult.TotpCodeScan(VALID_TOTP_CODE), + ) + } + verify { settingsRepository.defaultSaveOption = DefaultSaveOption.LOCAL } + } + + @Test + @Suppress("MaxLineLength") + fun `on SaveToBitwardenErrorDismiss recieve should clear dialog state`() { + val viewModel = createViewModel() + every { + authenticatorBridgeManager.startAddTotpLoginItemFlow(VALID_TOTP_CODE) + } returns false + // Show error dialog: + viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(VALID_TOTP_CODE)) + viewModel.trySendAction(QrCodeScanAction.SaveToBitwardenClick(false)) + val expectedState = + DEFAULT_STATE.copy(dialog = QrCodeScanState.DialogState.SaveToBitwardenError) + assertEquals(expectedState, viewModel.stateFlow.value) + verify { authenticatorBridgeManager.startAddTotpLoginItemFlow(VALID_TOTP_CODE) } + + // Clear dialog: + viewModel.trySendAction(QrCodeScanAction.SaveToBitwardenErrorDismiss) + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + } + + @Test + fun `on QrCodeScanReceive when authenticator sync is not enabled should just save locally`() { + val viewModel = createViewModel() + every { + authenticatorRepository.sharedCodesStateFlow.value + } returns SharedVerificationCodesState.SyncNotEnabled + every { + authenticatorRepository.emitTotpCodeResult(VALID_TOTP_RESULT) + } just runs + viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(VALID_TOTP_CODE)) + verify { authenticatorRepository.emitTotpCodeResult(VALID_TOTP_RESULT) } + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `on QrCodeScanReceive when default save option is local should save locally and navigate back`() = + runTest { + val viewModel = createViewModel() + every { settingsRepository.defaultSaveOption } returns DefaultSaveOption.LOCAL + every { + authenticatorRepository.sharedCodesStateFlow.value + } returns SharedVerificationCodesState.Success(emptyList()) + every { + authenticatorRepository.emitTotpCodeResult(VALID_TOTP_RESULT) + } just runs + viewModel.eventFlow.test { + viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(VALID_TOTP_CODE)) + assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) + } + verify { authenticatorRepository.emitTotpCodeResult(VALID_TOTP_RESULT) } + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `on QrCodeScanReceive when default save option is bitwarden should start navigate to Bitwarden flow`() = + runTest { + val viewModel = createViewModel() + every { settingsRepository.defaultSaveOption } returns DefaultSaveOption.BITWARDEN_APP + every { + authenticatorRepository.sharedCodesStateFlow.value + } returns SharedVerificationCodesState.Success(emptyList()) + every { + authenticatorBridgeManager.startAddTotpLoginItemFlow(VALID_TOTP_CODE) + } returns true + viewModel.eventFlow.test { + viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(VALID_TOTP_CODE)) + assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) + } + verify { authenticatorBridgeManager.startAddTotpLoginItemFlow(VALID_TOTP_CODE) } + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `on QrCodeScanReceive when code is invalid should emit result and navigate back`() = + runTest { + val viewModel = createViewModel() + every { + authenticatorRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError) + } just runs + val invalidUri: Uri = mockk { + every { getQueryParameter("secret") } returns "SECRET" + every { queryParameterNames } returns setOf("digits") + every { getQueryParameter("digits") } returns "100" + } + val invalidQrCode = "otpauth://totp/secret=SECRET" + every { Uri.parse(invalidQrCode) } returns invalidUri + viewModel.eventFlow.test { + viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidQrCode)) + assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) + } + verify { authenticatorRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError) } + } + + private fun createViewModel() = QrCodeScanViewModel( + authenticatorBridgeManager = authenticatorBridgeManager, + authenticatorRepository = authenticatorRepository, + settingsRepository = settingsRepository, + ) +} + +private val DEFAULT_STATE = QrCodeScanState( + dialog = null, +) +private const val VALID_TOTP_CODE = "otpauth://totp/Label?secret=SECRET&issuer=Issuer" +private val VALID_TOTP_URI: Uri = mockk { + every { getQueryParameter("secret") } returns "SECRET" + every { queryParameterNames } returns emptySet() +} +private val VALID_TOTP_RESULT = TotpCodeResult.TotpCodeScan(VALID_TOTP_CODE)