diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/TotpCodeManagerImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/TotpCodeManagerImpl.kt index 3bf3e3b327..9c33046ee7 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/TotpCodeManagerImpl.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/TotpCodeManagerImpl.kt @@ -106,16 +106,21 @@ class TotpCodeManagerImpl @Inject constructor( authenticatorSdkSource .generateTotp(item.otpUri, dateTime) .onSuccess { response -> + val period = response.period.toInt() + val nextCode = authenticatorSdkSource + .generateTotp(item.otpUri, dateTime.plusSeconds(period.toLong())) + .getOrNull() + ?.code verificationCodeItem = VerificationCodeItem( code = response.code, - periodSeconds = response.period.toInt(), - timeLeftSeconds = response.period.toInt() - - (time % response.period.toInt()), + periodSeconds = period, + timeLeftSeconds = period - (time % period), issueTime = clock.millis(), id = item.cipherId, issuer = item.issuer, label = item.label, source = item.source, + nextCode = nextCode, ) } .onFailure { diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/model/VerificationCodeItem.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/model/VerificationCodeItem.kt index 32e00d10e8..22a2c51e42 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/model/VerificationCodeItem.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/model/VerificationCodeItem.kt @@ -12,6 +12,8 @@ import com.bitwarden.authenticator.data.authenticator.repository.model.Authentic * @property issueTime The time the verification code was issued. * @property id The cipher id of the item. * @property username The username associated with the item. + * @property nextCode The next verification code that will become active after the current code + * expires, or null if not yet computed. */ data class VerificationCodeItem( val code: String, @@ -22,6 +24,7 @@ data class VerificationCodeItem( val issuer: String?, val label: String?, val source: AuthenticatorItem.Source, + val nextCode: String? = null, ) { /** * The composite label of the authenticator item. Used for constructing an OTPAuth URI. diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/util/VerificationCodeItemExtensions.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/util/VerificationCodeItemExtensions.kt index e5d147d798..9946320617 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/util/VerificationCodeItemExtensions.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/util/VerificationCodeItemExtensions.kt @@ -25,6 +25,7 @@ fun VerificationCodeItem.toDisplayItem( periodSeconds = periodSeconds, alertThresholdSeconds = alertThresholdSeconds, authCode = code, + nextAuthCode = nextCode, showOverflow = showOverflow, favorite = (source as? AuthenticatorItem.Source.Local)?.isFavorite ?: false, showMoveToBitwarden = when (source) { diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/listitem/VaultVerificationCodeItem.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/listitem/VaultVerificationCodeItem.kt index b72d5a0827..ff11709b4e 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/listitem/VaultVerificationCodeItem.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/listitem/VaultVerificationCodeItem.kt @@ -33,7 +33,7 @@ import com.bitwarden.ui.platform.theme.BitwardenTheme /** * The verification code item displayed to the user. * - * @param displayItem he model containing all relevant data to be displayed. + * @param displayItem The model containing all relevant data to be displayed. * @param onItemClick The lambda function to be invoked when the item is clicked. * @param onDropdownMenuClick A lambda function invoked when a dropdown menu action is clicked. * @param cardStyle The card style to be applied to this item. @@ -61,9 +61,12 @@ fun VaultVerificationCodeItem( showMoveToBitwarden = displayItem.showMoveToBitwarden, cardStyle = cardStyle, modifier = modifier, + nextAuthCode = displayItem.nextAuthCode, ) } +private const val NEXT_CODE_THRESHOLD_SECONDS = 5 + /** * The verification code item displayed to the user. * @@ -80,6 +83,8 @@ fun VaultVerificationCodeItem( * @param showMoveToBitwarden Whether the option to move the item to Bitwarden is displayed. * @param cardStyle The card style to be applied to this item. * @param modifier The modifier for the item. + * @param nextAuthCode The next verification code to display when the current code is near expiry, + * or null if not yet available. */ @Suppress("LongMethod", "MagicNumber") @Composable @@ -97,6 +102,7 @@ fun VaultVerificationCodeItem( showMoveToBitwarden: Boolean, cardStyle: CardStyle, modifier: Modifier = Modifier, + nextAuthCode: String? = null, ) { Row( modifier = modifier @@ -145,21 +151,39 @@ fun VaultVerificationCodeItem( } } - BitwardenCircularCountdownIndicator( - modifier = Modifier.testTag(tag = "CircularCountDown"), - timeLeftSeconds = timeLeftSeconds, - periodSeconds = periodSeconds, - alertThresholdSeconds = alertThresholdSeconds, - ) + Column(horizontalAlignment = Alignment.End) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(space = 16.dp), + ) { + BitwardenCircularCountdownIndicator( + modifier = Modifier.testTag(tag = "CircularCountDown"), + timeLeftSeconds = timeLeftSeconds, + periodSeconds = periodSeconds, + alertThresholdSeconds = alertThresholdSeconds, + ) - Text( - modifier = Modifier.testTag(tag = "AuthCode"), - text = authCode - .chunked(size = 3) { it.padEnd(length = 3, padChar = ' ') } - .joinToString(separator = " "), - style = BitwardenTheme.typography.sensitiveInfoSmall, - color = BitwardenTheme.colorScheme.text.primary, - ) + Text( + modifier = Modifier.testTag(tag = "AuthCode"), + text = authCode + .chunked(size = 3) { it.padEnd(length = 3, padChar = ' ') } + .joinToString(separator = " "), + style = BitwardenTheme.typography.sensitiveInfoSmall, + color = BitwardenTheme.colorScheme.text.primary, + ) + } + + if (nextAuthCode != null && timeLeftSeconds <= NEXT_CODE_THRESHOLD_SECONDS) { + Text( + modifier = Modifier.testTag(tag = "NextAuthCode"), + text = stringResource(id = BitwardenString.next_verification_code) + + " " + + nextAuthCode.chunked(size = 3).joinToString(separator = " "), + style = BitwardenTheme.typography.bodySmall, + color = BitwardenTheme.colorScheme.text.secondary, + ) + } + } if (showOverflow) { BitwardenOverflowActionItem( @@ -210,7 +234,7 @@ private fun VerificationCodeItem_preview() { primaryLabel = "Issuer, AKA Name", secondaryLabel = "username@bitwarden.com", periodSeconds = 30, - timeLeftSeconds = 15, + timeLeftSeconds = 3, alertThresholdSeconds = 7, startIcon = IconData.Local(BitwardenDrawable.ic_login_item), onItemClick = {}, @@ -219,6 +243,7 @@ private fun VerificationCodeItem_preview() { modifier = Modifier.padding(horizontal = 16.dp), showMoveToBitwarden = true, cardStyle = CardStyle.Full, + nextAuthCode = "987654321", ) } } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/listitem/model/VerificationCodeDisplayItem.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/listitem/model/VerificationCodeDisplayItem.kt index a5e2de91ce..62c0ee97c9 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/listitem/model/VerificationCodeDisplayItem.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/listitem/model/VerificationCodeDisplayItem.kt @@ -24,4 +24,5 @@ data class VerificationCodeDisplayItem( val favorite: Boolean, val showOverflow: Boolean, val showMoveToBitwarden: Boolean, + val nextAuthCode: String? = null, ) : Parcelable diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/authenticator/manager/util/TotpCodeManagerTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/authenticator/manager/util/TotpCodeManagerTest.kt index 0a2744aaaa..139bf21a54 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/authenticator/manager/util/TotpCodeManagerTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/authenticator/manager/util/TotpCodeManagerTest.kt @@ -48,16 +48,56 @@ class TotpCodeManagerTest { createMockAuthenticatorItem(number = 1, otpUri = totp), ) val code = "123456" - val totpResponse = TotpResponse(code = code, period = 30u) + val nextCode = "789012" + val period = 30u coEvery { authenticatorSdkSource.generateTotp(totp = totp, time = clock.instant()) - } returns totpResponse.asSuccess() + } returns TotpResponse(code = code, period = period).asSuccess() + coEvery { + authenticatorSdkSource.generateTotp( + totp = totp, + time = clock.instant().plusSeconds(period.toLong()), + ) + } returns TotpResponse(code = nextCode, period = period).asSuccess() val expected = createMockVerificationCodeItem( number = 1, code = code, issueTime = clock.instant().toEpochMilli(), timeLeftSeconds = 30, + nextCode = nextCode, + ) + + manager.getTotpCodesFlow(authenticatorItems).test { + assertEquals(listOf(expected), awaitItem()) + } + } + + @Test + fun `getTotpCodesFlow should emit item with null nextCode if next code generation fails`() = + runTest { + val totp = "otpUri" + val authenticatorItems = listOf( + createMockAuthenticatorItem(number = 1, otpUri = totp), + ) + val code = "123456" + val period = 30u + coEvery { + authenticatorSdkSource.generateTotp(totp = totp, time = clock.instant()) + } returns TotpResponse(code = code, period = period).asSuccess() + coEvery { + authenticatorSdkSource.generateTotp( + totp = totp, + time = clock.instant().plusSeconds(period.toLong()), + ) + } returns Exception().asFailure() + + val expected = createMockVerificationCodeItem( + number = 1, + code = code, + issueTime = clock.instant().toEpochMilli(), + timeLeftSeconds = 30, + nextCode = null, ) manager.getTotpCodesFlow(authenticatorItems).test { diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/authenticator/manager/util/VerificationCodeItemUtil.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/authenticator/manager/util/VerificationCodeItemUtil.kt index e04b701d13..84c007d660 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/authenticator/manager/util/VerificationCodeItemUtil.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/authenticator/manager/util/VerificationCodeItemUtil.kt @@ -43,6 +43,7 @@ fun createMockVerificationCodeItem( label: String = "mockLabel-$number", issuer: String = "mockIssuer-$number", source: AuthenticatorItem.Source = createMockLocalAuthenticatorItemSource(), + nextCode: String? = null, ): VerificationCodeItem = VerificationCodeItem( code = code, @@ -53,6 +54,7 @@ fun createMockVerificationCodeItem( label = label, issuer = issuer, source = source, + nextCode = nextCode, ) /** diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 5729b47b85..570259b5b0 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -197,6 +197,7 @@ Point your camera at the QR Code. Scanning will happen automatically. Copy TOTP + Next: If a login has an authenticator key, copy the TOTP verification code to your clipboard when you autofill the login. Copy TOTP automatically A Premium membership is required to use this feature.