BITAU-89 Show save location dialog on the QR scan screen (#258)

This commit is contained in:
Andrew Haisting
2024-10-30 10:02:03 -05:00
committed by GitHub
parent bb6255b6a4
commit 5de6dc3473
10 changed files with 786 additions and 8 deletions

View File

@@ -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
}

View File

@@ -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))
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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<Unit, QrCodeScanEvent, QrCodeScanAction>(
initialState = Unit,
private val settingsRepository: SettingsRepository,
) : BaseViewModel<QrCodeScanState, QrCodeScanEvent, QrCodeScanAction>(
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()
}

View File

@@ -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
}

View File

@@ -139,4 +139,9 @@
<string name="none">None</string>
<string name="default_save_options_subtitle">Select where you would like to save new verification codes.</string>
<string name="confirm">Confirm</string>
<string name="save_here">Save here</string>
<string name="take_me_to_bitwarden">Take me to Bitwarden</string>
<string name="verification_code_created">Verification code created</string>
<string name="choose_save_location_message">Save this authenticator key here, or add it to a login in your Bitwarden app.</string>
<string name="save_option_as_default">Save option as default</string>
</resources>

View File

@@ -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)
}
}

View File

@@ -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) }
}
}

View File

@@ -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<QrCodeScanEvent>()
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,
)

View File

@@ -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)