mirror of
https://github.com/bitwarden/android.git
synced 2026-03-15 23:58:32 -05:00
BITAU-89 Show save location dialog on the QR scan screen (#258)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user