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.