[PM-23681] Update TotpCodeManager to use CipherListView (#5532)

This commit is contained in:
Patrick Honkonen 2025-07-17 12:10:41 -04:00 committed by GitHub
parent 2d2a5e74da
commit fca4ebe023
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 470 additions and 25 deletions

View File

@ -0,0 +1,18 @@
package com.x8bit.bitwarden.data.autofill.util
import com.bitwarden.vault.CardListView
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherListViewType
import com.bitwarden.vault.LoginListView
/**
* Returns the [LoginListView] if the cipher is of type [CipherListViewType.Login], otherwise null.
*/
val CipherListView.login: LoginListView?
get() = (this.type as? CipherListViewType.Login)?.v1
/**
* Returns the [CardListView] if the cipher is of type [CipherListViewType.Card], otherwise null.
*/
val CipherListView.card: CardListView?
get() = (this.type as? CipherListViewType.Card)?.v1

View File

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.vault.manager
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem
import kotlinx.coroutines.flow.StateFlow
@ -18,6 +19,15 @@ interface TotpCodeManager {
cipherList: List<CipherView>,
): StateFlow<DataState<List<VerificationCodeItem>>>
/**
* Flow for getting a DataState with multiple verification code items for the given
* [cipherListViews].
*/
fun getTotpCodesForCipherListViewsStateFlow(
userId: String,
cipherListViews: List<CipherListView>,
): StateFlow<DataState<List<VerificationCodeItem>>>
/**
* Flow for getting a DataState with a single verification code item.
*/
@ -25,4 +35,13 @@ interface TotpCodeManager {
userId: String,
cipher: CipherView,
): StateFlow<DataState<VerificationCodeItem?>>
/**
* Flow for getting a DataState with a single verification code item for the given
* [cipherListView].
*/
fun getTotpCodeStateFlow(
userId: String,
cipherListView: CipherListView,
): StateFlow<DataState<VerificationCodeItem?>>
}

View File

@ -3,8 +3,10 @@ package com.x8bit.bitwarden.data.vault.manager
import com.bitwarden.core.DateTime
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherRepromptType
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.autofill.util.login
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem
import kotlinx.coroutines.CoroutineScope
@ -36,6 +38,9 @@ class TotpCodeManagerImpl(
private val mutableVerificationCodeStateFlowMap =
mutableMapOf<CipherView, StateFlow<DataState<VerificationCodeItem?>>>()
private val mutableCipherListViewVerificationCodeStateFlowMap =
mutableMapOf<CipherListView, StateFlow<DataState<VerificationCodeItem?>>>()
override fun getTotpCodesStateFlow(
userId: String,
cipherList: List<CipherView>,
@ -73,6 +78,43 @@ class TotpCodeManagerImpl(
cipher = cipher,
)
override fun getTotpCodesForCipherListViewsStateFlow(
userId: String,
cipherListViews: List<CipherListView>,
): StateFlow<DataState<List<VerificationCodeItem>>> {
// Generate state flows
val stateFlows = cipherListViews.map { cipherListView ->
getTotpCodeStateFlowInternal(userId, cipherListView)
}
return combine(stateFlows) { results ->
when {
results.any { it is DataState.Loading } -> {
DataState.Loading
}
else -> {
DataState.Loaded(
data = results.mapNotNull { (it as DataState.Loaded).data },
)
}
}
}
.stateIn(
scope = unconfinedScope,
started = SharingStarted.WhileSubscribed(),
initialValue = DataState.Loading,
)
}
override fun getTotpCodeStateFlow(
userId: String,
cipherListView: CipherListView,
): StateFlow<DataState<VerificationCodeItem?>> =
getTotpCodeStateFlowInternal(
userId = userId,
cipherListView = cipherListView,
)
@Suppress("LongMethod")
private fun getTotpCodeStateFlowInternal(
userId: String,
@ -108,7 +150,6 @@ class TotpCodeManagerImpl(
.onSuccess { response ->
item = VerificationCodeItem(
code = response.code,
totpCode = totpCode,
periodSeconds = response.period.toInt(),
timeLeftSeconds = response.period.toInt() -
time % response.period.toInt(),
@ -129,12 +170,9 @@ class TotpCodeManagerImpl(
return@flow
}
} else {
item?.let {
item = it.copy(
timeLeftSeconds = it.periodSeconds -
(time % it.periodSeconds),
)
}
item = item.copy(
timeLeftSeconds = item.periodSeconds - (time % item.periodSeconds),
)
}
item?.let {
@ -154,6 +192,78 @@ class TotpCodeManagerImpl(
)
}
}
@Suppress("LongMethod")
private fun getTotpCodeStateFlowInternal(
userId: String,
cipherListView: CipherListView?,
): StateFlow<DataState<VerificationCodeItem?>> {
val cipherId = cipherListView?.id ?: return MutableStateFlow(DataState.Loaded(null))
cipherListView.login?.totp ?: return MutableStateFlow(DataState.Loaded(null))
return mutableCipherListViewVerificationCodeStateFlowMap.getOrPut(cipherListView) {
// Define a per-item scope so that we can clear the Flow from the scope when it is
// no longer needed.
val itemScope = CoroutineScope(dispatcherManager.unconfined)
flow<DataState<VerificationCodeItem?>> {
var item: VerificationCodeItem? = null
while (currentCoroutineContext().isActive) {
val dateTime = clock.instant()
val time = dateTime.epochSecond.toInt()
if (item == null || item.isExpired(clock = clock)) {
vaultSdkSource
.generateTotpForCipherListView(
cipherListView = cipherListView,
userId = userId,
time = dateTime,
)
.onSuccess { response ->
item = VerificationCodeItem(
code = response.code,
periodSeconds = response.period.toInt(),
timeLeftSeconds = response.period.toInt() -
time % response.period.toInt(),
issueTime = clock.millis(),
uriLoginViewList = cipherListView.login?.uris,
id = cipherId,
name = cipherListView.name,
username = cipherListView.login?.username,
hasPasswordReprompt = when (cipherListView.reprompt) {
CipherRepromptType.PASSWORD -> true
CipherRepromptType.NONE -> false
},
orgUsesTotp = cipherListView.organizationUseTotp,
)
}
.onFailure {
emit(DataState.Loaded(null))
return@flow
}
} else {
item = item.copy(
timeLeftSeconds = item.periodSeconds - (time % item.periodSeconds),
)
}
item?.let {
emit(DataState.Loaded(it))
}
delay(ONE_SECOND_MILLISECOND)
}
}
.onCompletion {
mutableCipherListViewVerificationCodeStateFlowMap.remove(cipherListView)
itemScope.cancel()
}
.stateIn(
scope = itemScope,
started = SharingStarted.WhileSubscribed(),
initialValue = DataState.Loading,
)
}
}
}
private fun VerificationCodeItem.isExpired(clock: Clock): Boolean {

View File

@ -6,7 +6,6 @@ import com.bitwarden.vault.LoginUriView
* Models the items returned by the TotpCodeManager.
*
* @property code The verification code for the item.
* @property totpCode The totp code for the item.
* @property periodSeconds The time span where the code is valid in seconds.
* @property timeLeftSeconds The seconds remaining until a new code is required.
* @property issueTime The time the verification code was issued.
@ -19,7 +18,6 @@ import com.bitwarden.vault.LoginUriView
*/
data class VerificationCodeItem(
val code: String,
val totpCode: String,
val periodSeconds: Int,
val timeLeftSeconds: Int,
val issueTime: Long,

View File

@ -120,7 +120,6 @@ class VaultItemViewModel @Inject constructor(
TotpCodeItemData(
periodSeconds = it.periodSeconds,
timeLeftSeconds = it.timeLeftSeconds,
totpCode = it.totpCode,
verificationCode = it.code,
)
}

View File

@ -9,12 +9,10 @@ import kotlinx.parcelize.Parcelize
* @property periodSeconds The period for the verification code.
* @property timeLeftSeconds The time left for the verification timer.
* @property verificationCode The verification code for the item.
* @property totpCode The totp code for the item.
*/
@Parcelize
data class TotpCodeItemData(
val periodSeconds: Int,
val timeLeftSeconds: Int,
val verificationCode: String,
val totpCode: String,
) : Parcelable

View File

@ -0,0 +1,60 @@
package com.x8bit.bitwarden.data.autofill.util
import com.bitwarden.vault.CipherListViewType
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCardListView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherListView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockLoginListView
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertNotNull
import org.junit.jupiter.api.assertNull
class CipherListViewExtensionsTest {
@Test
fun `login should return LoginListView when type is Login`() {
val cipherListView = createMockCipherListView(
number = 1,
type = CipherListViewType.Login(createMockLoginListView(1)),
)
val loginListView = cipherListView.login
assertNotNull(loginListView)
}
@Test
fun `login should return null when type is not Login`() {
val cipherListViews = listOf(
createMockCipherListView(number = 1, type = CipherListViewType.SecureNote),
createMockCipherListView(
number = 2,
type = CipherListViewType.Card(createMockCardListView(number = 2)),
),
createMockCipherListView(number = 3, type = CipherListViewType.SshKey),
createMockCipherListView(number = 4, type = CipherListViewType.Identity),
)
cipherListViews.forEach { assertNull(it.login) }
}
@Test
fun `card should return CardListView when type is Card`() {
val cipherListView = createMockCipherListView(
number = 1,
type = CipherListViewType.Card(createMockCardListView(number = 1)),
)
val cardListView = cipherListView.card
assertNotNull(cardListView)
}
@Test
fun `card should return null when type is not Card`() {
listOf(
createMockCipherListView(
number = 1,
type = CipherListViewType.Login(createMockLoginListView(1)),
),
createMockCipherListView(number = 2, type = CipherListViewType.SecureNote),
createMockCipherListView(number = 3, type = CipherListViewType.SshKey),
createMockCipherListView(number = 4, type = CipherListViewType.Identity),
)
.forEach { assertNull(it.card) }
}
}

View File

@ -1,6 +1,5 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk
import com.bitwarden.core.DateTime
import com.bitwarden.core.DeriveKeyConnectorRequest
import com.bitwarden.core.DerivePinKeyResponse
import com.bitwarden.core.InitOrgCryptoRequest
@ -71,6 +70,9 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.security.MessageDigest
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
@Suppress("LargeClass")
class VaultSdkSourceTest {
@ -1030,7 +1032,7 @@ class VaultSdkSourceTest {
val totpResponse = TotpResponse("TestCode", 30u)
coEvery { clientVault.generateTotp(any(), any()) } returns totpResponse
val time = DateTime.now()
val time = FIXED_CLOCK.instant()
val result = vaultSdkSource.generateTotp(
userId = userId,
totp = "Totp",
@ -1469,3 +1471,7 @@ private val DEFAULT_FIDO_2_AUTH_REQUEST = AuthenticateFido2CredentialRequest(
isUserVerificationSupported = true,
selectedCipherView = createMockCipherView(number = 1),
)
private val FIXED_CLOCK: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)

View File

@ -0,0 +1,123 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
import com.bitwarden.vault.CardListView
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherListViewType
import com.bitwarden.vault.CipherPermissions
import com.bitwarden.vault.CipherRepromptType
import com.bitwarden.vault.CopyableCipherFields
import com.bitwarden.vault.Fido2CredentialListView
import com.bitwarden.vault.LocalDataView
import com.bitwarden.vault.LoginListView
import com.bitwarden.vault.LoginUriView
import java.time.Instant
import java.time.ZonedDateTime
/**
* Default date time used for [ZonedDateTime] properties of mock objects.
*/
private const val DEFAULT_TIMESTAMP = "2023-10-27T12:00:00Z"
/**
* Creates a mock [CipherListView] for testing. Defaults to a Login cipher. Set [type] to override
* the default behavior.
*/
@Suppress("LongParameterList")
fun createMockCipherListView(
number: Int,
id: String = "mockId-$number",
organizationId: String? = "mockOrganizationId-$number",
folderId: String? = "mockId-$number",
type: CipherListViewType = CipherListViewType.Login(
createMockLoginListView(number = 1),
),
reprompt: CipherRepromptType = CipherRepromptType.NONE,
name: String = "mockName-$number",
favorite: Boolean = false,
collectionIds: List<String> = listOf("mockId-$number"),
revisionDate: Instant = Instant.parse(DEFAULT_TIMESTAMP),
creationDate: Instant = Instant.parse(DEFAULT_TIMESTAMP),
attachments: UInt = 0U,
organizationUseTotp: Boolean = false,
edit: Boolean = false,
viewPassword: Boolean = false,
permissions: CipherPermissions? = createMockSdkCipherPermissions(),
localData: LocalDataView? = null,
key: String = "mockKey-$number",
subtitle: String = "mockSubtitle-$number",
hasOldAttachments: Boolean = false,
copyableFields: List<CopyableCipherFields> = emptyList(),
isDeleted: Boolean = false,
): CipherListView = CipherListView(
id = id,
organizationId = organizationId,
folderId = folderId,
type = type,
reprompt = reprompt,
name = name,
favorite = favorite,
collectionIds = collectionIds,
revisionDate = revisionDate,
creationDate = creationDate,
deletedDate = if (isDeleted) Instant.parse(DEFAULT_TIMESTAMP) else null,
attachments = attachments,
organizationUseTotp = organizationUseTotp,
edit = edit,
viewPassword = viewPassword,
permissions = permissions,
localData = localData,
key = key,
subtitle = subtitle,
hasOldAttachments = hasOldAttachments,
copyableFields = copyableFields,
)
/**
* Creates a mock [LoginListView] for testing.
*/
@Suppress("LongParameterList")
fun createMockLoginListView(
number: Int,
fido2Credentials: List<Fido2CredentialListView> = listOf(
createMockFido2CredentialListView(number = 1),
),
hasFido2: Boolean = true,
username: String = "mockUsername-$number",
totp: String? = "mockTotp-$number",
uris: List<LoginUriView> = listOf(createMockUriView(number = 1)),
): LoginListView = LoginListView(
fido2Credentials = fido2Credentials,
hasFido2 = hasFido2,
username = username,
totp = totp,
uris = uris,
)
/**
* Creates a mock [Fido2CredentialListView] for testing.
*/
@Suppress("LongParameterList")
fun createMockFido2CredentialListView(
number: Int,
credentialId: String = "mockCredentialId-$number",
rpId: String = "mockRpId-$number",
userHandle: String = "mockUserHandle-$number",
userName: String = "mockUserName-$number",
userDisplayName: String = "mockUserDisplayName-$number",
): Fido2CredentialListView = Fido2CredentialListView(
credentialId = credentialId,
rpId = rpId,
userHandle = userHandle,
userName = userName,
userDisplayName = userDisplayName,
)
/**
* Creates a mock [CardListView] for testing.
*/
fun createMockCardListView(
number: Int,
brand: String = "mockBrand-$number",
): CardListView = CardListView(
brand = brand,
)

View File

@ -6,10 +6,13 @@ import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.data.datasource.disk.base.FakeDispatcherManager
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.vault.CipherListViewType
import com.bitwarden.vault.CipherRepromptType
import com.bitwarden.vault.TotpResponse
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherListView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockLoginListView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockLoginView
import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem
import com.x8bit.bitwarden.ui.vault.feature.verificationcode.util.createVerificationCodeItem
@ -129,4 +132,125 @@ class TotpCodeManagerTest {
assertEquals(DataState.Loaded(null), awaitItem())
}
}
@Test
@Suppress("MaxLineLength")
fun `getTotpCodesForCipherListViewsStateFlow should have loaded data with a valid values passed in`() =
runTest {
val cipherListViews = listOf(
createMockCipherListView(number = 1),
)
val totpResponse = TotpResponse("123456", 30u)
coEvery {
vaultSdkSource.generateTotpForCipherListView(
userId = any(),
cipherListView = any(),
time = any(),
)
} returns totpResponse.asSuccess()
val expected = createVerificationCodeItem()
totpCodeManager.getTotpCodesForCipherListViewsStateFlow(userId, cipherListViews).test {
assertEquals(DataState.Loaded(listOf(expected)), awaitItem())
}
}
@Test
@Suppress("MaxLineLength")
fun `getTotpCodesForCipherListViewsStateFlow should have loaded data with empty list if no totp code is provided`() =
runTest {
val totpResponse = TotpResponse("123456", 30u)
coEvery {
vaultSdkSource.generateTotpForCipherListView(
userId = any(),
cipherListView = any(),
time = any(),
)
} returns totpResponse.asSuccess()
val cipherListView = createMockCipherListView(
number = 1,
type = CipherListViewType.Login(
createMockLoginListView(
number = 1,
totp = null,
),
),
)
totpCodeManager.getTotpCodesForCipherListViewsStateFlow(userId, listOf(cipherListView))
.test {
assertEquals(DataState.Loaded(emptyList<VerificationCodeItem>()), awaitItem())
}
}
@Suppress("MaxLineLength")
@Test
fun `getTotpCodesForCipherListViewsStateFlow should have loaded data with empty list if unable to generate auth code`() =
runTest {
coEvery {
vaultSdkSource.generateTotpForCipherListView(
userId = any(),
cipherListView = any(),
time = any(),
)
} returns Exception().asFailure()
val cipherListView = createMockCipherListView(
number = 1,
type = CipherListViewType.Login(createMockLoginListView(number = 1)),
)
totpCodeManager.getTotpCodesForCipherListViewsStateFlow(userId, listOf(cipherListView)).test {
assertEquals(DataState.Loaded(emptyList<VerificationCodeItem>()), awaitItem())
}
}
@Test
@Suppress("MaxLineLength")
fun `getTotpCodeStateFlow from CipherListView should have loaded item with valid data passed in`() =
runTest {
val totpResponse = TotpResponse("123456", 30u)
coEvery {
vaultSdkSource.generateTotpForCipherListView(
userId = any(),
cipherListView = any(),
time = any(),
)
} returns totpResponse.asSuccess()
val cipherListView = createMockCipherListView(
number = 1,
reprompt = CipherRepromptType.PASSWORD,
)
val expected = createVerificationCodeItem().copy(hasPasswordReprompt = true)
totpCodeManager.getTotpCodeStateFlow(userId, cipherListView).test {
assertEquals(DataState.Loaded(expected), awaitItem())
}
}
@Test
fun `getTotpCodeFlow from CipherListView should have null data if unable to get item`() =
runTest {
val totpResponse = TotpResponse("123456", 30u)
coEvery {
vaultSdkSource.generateTotp(
userId = any(),
totp = any(),
time = any(),
)
} returns totpResponse.asSuccess()
val cipherListView = createMockCipherListView(
number = 1,
type = CipherListViewType.SshKey,
)
totpCodeManager.getTotpCodeStateFlow(userId, cipherListView).test {
assertEquals(DataState.Loaded(null), awaitItem())
}
}
}

View File

@ -3180,7 +3180,7 @@ class VaultRepositoryTest {
)
every {
totpCodeManager.getTotpCodeStateFlow(userId = userId, any())
totpCodeManager.getTotpCodeStateFlow(userId = userId, cipher = any())
} returns stateFlow
setupDataStateFlow(userId = userId)

View File

@ -2103,7 +2103,6 @@ class VaultItemScreenTest : BitwardenComposeTest() {
periodSeconds = 30,
timeLeftSeconds = 15,
verificationCode = "123456",
totpCode = "testCode",
),
),
),
@ -3226,7 +3225,6 @@ private val DEFAULT_LOGIN: VaultItemState.ViewState.Content.ItemType.Login =
periodSeconds = 30,
timeLeftSeconds = 15,
verificationCode = "123456",
totpCode = "testCode",
),
fido2CredentialCreationDateText = null,
canViewTotpCode = true,

View File

@ -2482,7 +2482,6 @@ class VaultItemViewModelTest : BaseViewModelTest() {
periodSeconds = 30,
timeLeftSeconds = 30,
verificationCode = "123456",
totpCode = "mockTotp-1",
)
private fun setupMockUri() {
@ -2549,8 +2548,6 @@ class VaultItemViewModelTest : BaseViewModelTest() {
passwordRevisionDate = R.string.password_last_updated.asText("12/31/69 06:16 PM"),
isPremiumUser = true,
totpCodeItemData = TotpCodeItemData(
totpCode = "otpauth://totp/Example:alice@google.com" +
"?secret=JBSWY3DPEHPK3PXP&issuer=Example",
verificationCode = "123456",
timeLeftSeconds = 15,
periodSeconds = 30,

View File

@ -48,7 +48,6 @@ class CipherViewExtensionsTest {
periodSeconds = 30,
timeLeftSeconds = 15,
verificationCode = "123456",
totpCode = "testCode",
),
clock = fixedClock,
canDelete = true,
@ -82,7 +81,6 @@ class CipherViewExtensionsTest {
periodSeconds = 30,
timeLeftSeconds = 15,
verificationCode = "123456",
totpCode = "testCode",
),
clock = fixedClock,
canDelete = true,
@ -122,7 +120,6 @@ class CipherViewExtensionsTest {
periodSeconds = 30,
timeLeftSeconds = 15,
verificationCode = "123456",
totpCode = "testCode",
),
clock = fixedClock,
canDelete = true,

View File

@ -278,7 +278,6 @@ fun createLoginContent(isEmpty: Boolean): VaultItemState.ViewState.Content.ItemT
periodSeconds = 30,
timeLeftSeconds = 15,
verificationCode = "123456",
totpCode = "testCode",
)
.takeUnless { isEmpty },
fido2CredentialCreationDateText = R.string.created_x

View File

@ -6,7 +6,6 @@ import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem
fun createVerificationCodeItem(number: Int = 1) =
VerificationCodeItem(
code = "123456",
totpCode = "mockTotp-$number",
periodSeconds = 30,
id = "mockId-$number",
issueTime = 1698408000000,