PM-27817: Consolidate totp parsing with TotpUriUtils (#6122)

This commit is contained in:
David Perez 2025-11-04 15:51:01 -06:00 committed by GitHub
parent ed47ff4d18
commit 2bb06063c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 69 additions and 344 deletions

View File

@ -1,21 +1,14 @@
package com.x8bit.bitwarden.ui.vault.feature.qrcodescan package com.x8bit.bitwarden.ui.vault.feature.qrcodescan
import android.net.Uri
import com.bitwarden.ui.platform.base.BaseViewModel import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.util.getTotpDataOrNull
import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
private const val ALGORITHM = "algorithm"
private const val DIGITS = "digits"
private const val PERIOD = "period"
private const val SECRET = "secret"
private const val TOTP_CODE_PREFIX = "otpauth://totp"
/** /**
* Handles [QrCodeScanAction], * Handles [QrCodeScanAction] and launches [QrCodeScanEvent] for the [QrCodeScanScreen].
* and launches [QrCodeScanEvent] for the [QrCodeScanScreen].
*/ */
@HiltViewModel @HiltViewModel
class QrCodeScanViewModel @Inject constructor( class QrCodeScanViewModel @Inject constructor(
@ -33,83 +26,25 @@ class QrCodeScanViewModel @Inject constructor(
} }
private fun handleCloseClick() { private fun handleCloseClick() {
sendEvent( sendEvent(QrCodeScanEvent.NavigateBack)
QrCodeScanEvent.NavigateBack,
)
} }
private fun handleManualEntryTextClick() { private fun handleManualEntryTextClick() {
sendEvent( sendEvent(QrCodeScanEvent.NavigateToManualCodeEntry)
QrCodeScanEvent.NavigateToManualCodeEntry,
)
} }
// For more information: https://bitwarden.com/help/authenticator-keys/#support-for-more-parameters // For more information: https://bitwarden.com/help/authenticator-keys/#support-for-more-parameters
private fun handleQrCodeScanReceive(action: QrCodeScanAction.QrCodeScanReceive) { private fun handleQrCodeScanReceive(action: QrCodeScanAction.QrCodeScanReceive) {
var result: TotpCodeResult = TotpCodeResult.Success(action.qrCode) val qrCode = action.qrCode
val scannedCode = action.qrCode qrCode
.getTotpDataOrNull()
if (scannedCode.isBlank() || !scannedCode.startsWith(TOTP_CODE_PREFIX)) { ?.let { vaultRepository.emitTotpCodeResult(TotpCodeResult.Success(code = qrCode)) }
vaultRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError()) ?: run { vaultRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError()) }
sendEvent(QrCodeScanEvent.NavigateBack)
return
}
val scannedCodeUri = Uri.parse(scannedCode)
val secretValue = scannedCodeUri.getQueryParameter(SECRET)
if (secretValue == null || !secretValue.isBase32()) {
vaultRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError())
sendEvent(QrCodeScanEvent.NavigateBack)
return
}
val values = scannedCodeUri.queryParameterNames
if (!areParametersValid(scannedCode, values)) {
result = TotpCodeResult.CodeScanningError()
}
vaultRepository.emitTotpCodeResult(result)
sendEvent(QrCodeScanEvent.NavigateBack) sendEvent(QrCodeScanEvent.NavigateBack)
} }
private fun handleCameraErrorReceive() { private fun handleCameraErrorReceive() {
sendEvent( sendEvent(QrCodeScanEvent.NavigateToManualCodeEntry)
QrCodeScanEvent.NavigateToManualCodeEntry,
)
}
@Suppress("NestedBlockDepth", "MagicNumber")
private fun areParametersValid(scannedCode: String, parameters: Set<String>): Boolean {
parameters.forEach { parameter ->
Uri.parse(scannedCode).getQueryParameter(parameter)?.let { value ->
when (parameter) {
DIGITS -> {
val digit = value.toInt()
if (digit > 10 || digit < 1) {
return false
}
}
PERIOD -> {
val period = value.toInt()
if (period < 1) {
return false
}
}
ALGORITHM -> {
val lowercaseAlgo = value.lowercase()
if (lowercaseAlgo != "sha1" &&
lowercaseAlgo != "sha256" &&
lowercaseAlgo != "sha512"
) {
return false
}
}
}
}
}
return true
} }
} }
@ -154,11 +89,3 @@ sealed class QrCodeScanAction {
*/ */
data object CameraSetupErrorReceive : QrCodeScanAction() data object CameraSetupErrorReceive : QrCodeScanAction()
} }
/**
* Checks if a string is using base32 digits.
*/
private fun String.isBase32(): Boolean {
val regex = ("^[A-Za-z2-7]+=*$").toRegex()
return regex.matches(this)
}

View File

@ -1,9 +1,9 @@
package com.x8bit.bitwarden.ui.vault.feature.qrcodescan package com.x8bit.bitwarden.ui.vault.feature.qrcodescan
import android.net.Uri
import app.cash.turbine.test import app.cash.turbine.test
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.ui.platform.base.BaseViewModelTest import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.platform.util.getTotpDataOrNull
import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
import io.mockk.every import io.mockk.every
@ -27,16 +27,15 @@ class QrCodeScanViewModelTest : BaseViewModelTest() {
every { totpCodeFlow } returns totpTestCodeFlow every { totpCodeFlow } returns totpTestCodeFlow
every { emitTotpCodeResult(any()) } just runs every { emitTotpCodeResult(any()) } just runs
} }
private val uriMock = mockk<Uri>()
@BeforeEach @BeforeEach
fun setup() { fun setup() {
mockkStatic(Uri::class) mockkStatic(String::getTotpDataOrNull)
} }
@AfterEach @AfterEach
fun tearDown() { fun tearDown() {
unmockkStatic(Uri::class) unmockkStatic(String::getTotpDataOrNull)
} }
@Test @Test
@ -76,32 +75,11 @@ class QrCodeScanViewModelTest : BaseViewModelTest() {
} }
@Test @Test
fun `QrCodeScan should emit new code and NavigateBack with a valid code with all values`() = fun `QrCodeScanReceive with valid code should emit new code and NavigateBack`() = runTest {
runTest {
setupMockUri()
val validCode =
"otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP&algorithm=sha256&digits=8&period=60"
val viewModel = createViewModel()
val result = TotpCodeResult.Success(validCode)
viewModel.eventFlow.test {
viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(validCode))
verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) }
assertEquals(QrCodeScanEvent.NavigateBack, awaitItem())
}
}
@Test
fun `QrCodeScan should emit new code and NavigateBack without optional values`() = runTest {
setupMockUri(
queryParameterNames = setOf(SECRET),
)
val validCode = "otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP"
val viewModel = createViewModel() val viewModel = createViewModel()
val validCode = "otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP"
val result = TotpCodeResult.Success(validCode) val result = TotpCodeResult.Success(validCode)
every { validCode.getTotpDataOrNull() } returns mockk()
viewModel.eventFlow.test { viewModel.eventFlow.test {
viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(validCode)) viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(validCode))
@ -112,60 +90,11 @@ class QrCodeScanViewModelTest : BaseViewModelTest() {
} }
@Test @Test
fun `QrCodeScan should emit failure result and NavigateBack with invalid algorithm`() = fun `QrCodeScanReceive with invalid totp should emit failure result`() = runTest {
runTest {
setupMockUri(algorithm = "SHA-224")
val viewModel = createViewModel()
val result = TotpCodeResult.CodeScanningError()
val invalidCode = "otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP&algorithm=sha224"
viewModel.eventFlow.test {
viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode))
verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) }
assertEquals(QrCodeScanEvent.NavigateBack, awaitItem())
}
}
@Test
fun `QrCodeScan should emit failure result and NavigateBack with invalid digits`() = runTest {
setupMockUri(digits = "11")
val viewModel = createViewModel()
val result = TotpCodeResult.CodeScanningError()
val invalidCode = "otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP&digits=11"
viewModel.eventFlow.test {
viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode))
verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) }
assertEquals(QrCodeScanEvent.NavigateBack, awaitItem())
}
}
@Test
fun `QrCodeScan should emit failure result and NavigateBack with invalid period`() = runTest {
setupMockUri(period = "0")
val viewModel = createViewModel()
val result = TotpCodeResult.CodeScanningError()
val invalidCode = "otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP&period=0"
viewModel.eventFlow.test {
viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode))
verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) }
assertEquals(QrCodeScanEvent.NavigateBack, awaitItem())
}
}
@Test
fun `QrCodeScan should emit failure result without correct prefix`() = runTest {
val viewModel = createViewModel() val viewModel = createViewModel()
val result = TotpCodeResult.CodeScanningError() val result = TotpCodeResult.CodeScanningError()
val invalidCode = "nototpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP" val invalidCode = "nototpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP"
every { invalidCode.getTotpDataOrNull() } returns null
viewModel.eventFlow.test { viewModel.eventFlow.test {
viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode)) viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode))
@ -175,94 +104,8 @@ class QrCodeScanViewModelTest : BaseViewModelTest() {
} }
} }
@Test
fun `QrCodeScan should emit failure result with non base32 secret`() = runTest {
setupMockUri(secret = "JBSWY3dpeHPK3PXP1")
val viewModel = createViewModel()
val result = TotpCodeResult.CodeScanningError()
val invalidCode = "otpauth://totp/Test:me?secret=JBSWY3dpeHPK3PXP1"
viewModel.eventFlow.test {
viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode))
verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) }
assertEquals(QrCodeScanEvent.NavigateBack, awaitItem())
}
}
@Test
fun `QrCodeScan should emit failure result and NavigateBack without Secret`() = runTest {
setupMockUri(secret = null)
val viewModel = createViewModel()
val result = TotpCodeResult.CodeScanningError()
val invalidCode = "otpauth://totp/Test:me"
viewModel.eventFlow.test {
viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode))
verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) }
assertEquals(QrCodeScanEvent.NavigateBack, awaitItem())
}
}
@Test
fun `QrCodeScan should emit failure result and NavigateBack if secret is empty`() = runTest {
setupMockUri(secret = "")
val viewModel = createViewModel()
val result = TotpCodeResult.CodeScanningError()
val invalidCode = "otpauth://totp/Test:me?secret= "
viewModel.eventFlow.test {
viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode))
verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) }
assertEquals(QrCodeScanEvent.NavigateBack, awaitItem())
}
}
@Test
fun `QrCodeScan should emit failure result and NavigateBack if code is empty`() = runTest {
val viewModel = createViewModel()
val result = TotpCodeResult.CodeScanningError()
val invalidCode = ""
viewModel.eventFlow.test {
viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidCode))
verify(exactly = 1) { vaultRepository.emitTotpCodeResult(result) }
assertEquals(QrCodeScanEvent.NavigateBack, awaitItem())
}
}
private fun setupMockUri(
secret: String? = "JBSWY3dpeHPK3PXP",
algorithm: String = "SHA256",
digits: String = "8",
period: String = "60",
queryParameterNames: Set<String> = setOf(
ALGORITHM, PERIOD, DIGITS, SECRET,
),
) {
every { Uri.parse(any()) } returns uriMock
every { uriMock.getQueryParameter(SECRET) } returns secret
every { uriMock.getQueryParameter(ALGORITHM) } returns algorithm
every { uriMock.getQueryParameter(DIGITS) } returns digits
every { uriMock.getQueryParameter(PERIOD) } returns period
every { uriMock.queryParameterNames } returns queryParameterNames
}
private fun createViewModel(): QrCodeScanViewModel = private fun createViewModel(): QrCodeScanViewModel =
QrCodeScanViewModel( QrCodeScanViewModel(
vaultRepository = vaultRepository, vaultRepository = vaultRepository,
) )
companion object {
private const val ALGORITHM = "algorithm"
private const val DIGITS = "digits"
private const val PERIOD = "period"
private const val SECRET = "secret"
}
} }

View File

@ -1,9 +1,7 @@
package com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan package com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan
import android.net.Uri
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.ui.text.intl.Locale import androidx.core.net.toUri
import androidx.compose.ui.text.toUpperCase
import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager
import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository
import com.bitwarden.authenticator.data.authenticator.repository.model.TotpCodeResult import com.bitwarden.authenticator.data.authenticator.repository.model.TotpCodeResult
@ -12,15 +10,14 @@ import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption
import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager
import com.bitwarden.ui.platform.base.BaseViewModel import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.base.util.isBase32 import com.bitwarden.ui.platform.util.getTotpDataOrNull
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import javax.inject.Inject import javax.inject.Inject
/** /**
* Handles [QrCodeScanAction], * Handles [QrCodeScanAction] and launches [QrCodeScanEvent] for the [QrCodeScanScreen].
* and launches [QrCodeScanEvent] for the [QrCodeScanScreen].
*/ */
@HiltViewModel @HiltViewModel
@Suppress("TooManyFunctions") @Suppress("TooManyFunctions")
@ -77,15 +74,11 @@ class QrCodeScanViewModel @Inject constructor(
} }
private fun handleCloseClick() { private fun handleCloseClick() {
sendEvent( sendEvent(QrCodeScanEvent.NavigateBack)
QrCodeScanEvent.NavigateBack,
)
} }
private fun handleManualEntryTextClick() { private fun handleManualEntryTextClick() {
sendEvent( sendEvent(QrCodeScanEvent.NavigateToManualCodeEntry)
QrCodeScanEvent.NavigateToManualCodeEntry,
)
} }
private fun handleQrCodeScanReceive(action: QrCodeScanAction.QrCodeScanReceive) { private fun handleQrCodeScanReceive(action: QrCodeScanAction.QrCodeScanReceive) {
@ -103,96 +96,48 @@ class QrCodeScanViewModel @Inject constructor(
// For more information: https://bitwarden.com/help/authenticator-keys/#support-for-more-parameters // For more information: https://bitwarden.com/help/authenticator-keys/#support-for-more-parameters
private fun handleTotpUriReceive(scannedCode: String) { private fun handleTotpUriReceive(scannedCode: String) {
val result = TotpCodeResult.TotpCodeScan(scannedCode) scannedCode
val scannedCodeUri = Uri.parse(scannedCode) .getTotpDataOrNull()
val secretValue = scannedCodeUri ?.let {
.getQueryParameter(TotpCodeManager.SECRET_PARAM) val result = TotpCodeResult.TotpCodeScan(code = scannedCode)
.orEmpty() if (authenticatorRepository.sharedCodesStateFlow.value.isSyncWithBitwardenEnabled) {
.toUpperCase(Locale.current) when (settingsRepository.defaultSaveOption) {
DefaultSaveOption.BITWARDEN_APP -> {
saveCodeToBitwardenAndNavigateBack(result = result)
}
if (secretValue.isEmpty() || !secretValue.isBase32()) { DefaultSaveOption.LOCAL -> saveCodeLocallyAndNavigateBack(result = result)
authenticatorRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError) DefaultSaveOption.NONE -> {
sendEvent(QrCodeScanEvent.NavigateBack) pendingSuccessfulScan = result
return mutableStateFlow.update {
} it.copy(dialog = QrCodeScanState.DialogState.ChooseSaveLocation)
}
val values = scannedCodeUri.queryParameterNames }
// If the parameters are not valid,
if (!areParametersValid(scannedCode, values)) {
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 = result)
} }
} }
} else { ?: run {
// Syncing with Bitwarden not enabled, save code locally: authenticatorRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError)
saveCodeLocallyAndNavigateBack(result) sendEvent(QrCodeScanEvent.NavigateBack)
} }
} }
private fun handleGoogleExportUriReceive(scannedCode: String) { private fun handleGoogleExportUriReceive(scannedCode: String) {
val uri = Uri.parse(scannedCode) val uri = scannedCode.toUri()
val encodedData = uri.getQueryParameter(TotpCodeManager.DATA_PARAM) val encodedData = uri.getQueryParameter(TotpCodeManager.DATA_PARAM)
val result: TotpCodeResult = if (encodedData.isNullOrEmpty()) { if (encodedData.isNullOrEmpty()) {
TotpCodeResult.CodeScanningError authenticatorRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError)
} else { } else {
TotpCodeResult.GoogleExportScan(encodedData) authenticatorRepository.emitTotpCodeResult(TotpCodeResult.GoogleExportScan(encodedData))
} }
authenticatorRepository.emitTotpCodeResult(result)
sendEvent(QrCodeScanEvent.NavigateBack) sendEvent(QrCodeScanEvent.NavigateBack)
} }
private fun handleCameraErrorReceive() { private fun handleCameraErrorReceive() {
sendEvent( sendEvent(QrCodeScanEvent.NavigateToManualCodeEntry)
QrCodeScanEvent.NavigateToManualCodeEntry,
)
}
@Suppress("NestedBlockDepth", "ReturnCount", "MagicNumber")
private fun areParametersValid(scannedCode: String, parameters: Set<String>): Boolean {
parameters.forEach { parameter ->
Uri.parse(scannedCode).getQueryParameter(parameter)?.let { value ->
when (parameter) {
TotpCodeManager.DIGITS_PARAM -> {
val digit = value.toInt()
if (digit > 10 || digit < 1) {
return false
}
}
TotpCodeManager.PERIOD_PARAM -> {
val period = value.toInt()
if (period < 1) {
return false
}
}
TotpCodeManager.ALGORITHM_PARAM -> {
val lowercaseAlgo = value.lowercase()
if (lowercaseAlgo != "sha1" &&
lowercaseAlgo != "sha256" &&
lowercaseAlgo != "sha512"
) {
return false
}
}
}
}
}
return true
} }
private fun saveCodeToBitwardenAndNavigateBack(result: TotpCodeResult.TotpCodeScan) { private fun saveCodeToBitwardenAndNavigateBack(result: TotpCodeResult.TotpCodeScan) {

View File

@ -9,6 +9,7 @@ import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption
import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager import com.bitwarden.authenticatorbridge.manager.AuthenticatorBridgeManager
import com.bitwarden.ui.platform.base.BaseViewModelTest import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.platform.util.getTotpDataOrNull
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
@ -37,13 +38,20 @@ class QrCodeScanViewModelTest : BaseViewModelTest() {
@BeforeEach @BeforeEach
fun setup() { fun setup() {
mockkStatic(Uri::parse) mockkStatic(
String::getTotpDataOrNull,
Uri::parse,
)
every { VALID_TOTP_CODE.getTotpDataOrNull() } returns mockk()
every { Uri.parse(VALID_TOTP_CODE) } returns VALID_TOTP_URI every { Uri.parse(VALID_TOTP_CODE) } returns VALID_TOTP_URI
} }
@AfterEach @AfterEach
fun teardown() { fun teardown() {
unmockkStatic(Uri::parse) unmockkStatic(
String::getTotpDataOrNull,
Uri::parse,
)
} }
@Test @Test
@ -242,13 +250,8 @@ class QrCodeScanViewModelTest : BaseViewModelTest() {
every { every {
authenticatorRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError) authenticatorRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError)
} just runs } 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" val invalidQrCode = "otpauth://totp/secret=SECRET"
every { Uri.parse(invalidQrCode) } returns invalidUri every { invalidQrCode.getTotpDataOrNull() } returns null
viewModel.eventFlow.test { viewModel.eventFlow.test {
viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidQrCode)) viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(invalidQrCode))
assertEquals(QrCodeScanEvent.NavigateBack, awaitItem()) assertEquals(QrCodeScanEvent.NavigateBack, awaitItem())

View File

@ -1,6 +1,7 @@
package com.bitwarden.ui.platform.util package com.bitwarden.ui.platform.util
import android.net.Uri import android.net.Uri
import androidx.core.net.toUri
import com.bitwarden.ui.platform.base.util.isBase32 import com.bitwarden.ui.platform.base.util.isBase32
import com.bitwarden.ui.platform.model.TotpData import com.bitwarden.ui.platform.model.TotpData
@ -12,6 +13,12 @@ private const val PARAM_NAME_ISSUER: String = "issuer"
private const val PARAM_NAME_PERIOD: String = "period" private const val PARAM_NAME_PERIOD: String = "period"
private const val PARAM_NAME_SECRET: String = "secret" private const val PARAM_NAME_SECRET: String = "secret"
/**
* Checks if the given [String] contains valid data for a TOTP. The [TotpData] will be returned
* when the correct data is present or `null` if data is invalid or missing.
*/
fun String.getTotpDataOrNull(): TotpData? = this.toUri().getTotpDataOrNull()
/** /**
* Checks if the given [Uri] contains valid data for a TOTP. The [TotpData] will be returned when * Checks if the given [Uri] contains valid data for a TOTP. The [TotpData] will be returned when
* the correct data is present or `null` if data is invalid or missing. * the correct data is present or `null` if data is invalid or missing.