mirror of
https://github.com/bitwarden/android.git
synced 2026-05-11 02:15:43 -05:00
Compare commits
4 Commits
cron-sync-
...
BWA-99/add
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cd0894aff | ||
|
|
380dcaddbb | ||
|
|
79c15ea9d5 | ||
|
|
90f8994a2f |
340
.claude/outputs/plans/BWA-99-PREVIEW-NEXT-TOTP-CODE-PLAN.md
Normal file
340
.claude/outputs/plans/BWA-99-PREVIEW-NEXT-TOTP-CODE-PLAN.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# Preview Next TOTP Code — Design Document
|
||||
|
||||
**Feature**: Add a user-controlled setting that always displays the upcoming TOTP code below the current code in the authenticator item list.
|
||||
**Date**: 2026-04-22
|
||||
**Status**: Ready for Implementation
|
||||
**Jira**: BWA-99
|
||||
**Sources**:
|
||||
- [BWA-99](https://bitwarden.atlassian.net/browse/BWA-99) — Parent story
|
||||
- [BWA-249](https://bitwarden.atlassian.net/browse/BWA-249) — QA Test Cases subtask
|
||||
- Figma design node `4081-6476` (Bitwarden Authenticator Phase 1)
|
||||
- Ente Auth design reference (attached to BWA-99)
|
||||
|
||||
---
|
||||
|
||||
## Requirements Specification
|
||||
|
||||
### Overview
|
||||
|
||||
This feature adds a user-controlled toggle in Settings that, when enabled, always displays the next TOTP code directly below the current code for each item in the authenticator list. The goal is to let users see the upcoming code before the current one expires, avoiding the need to wait through the countdown. The feature is purely additive and scoped to the `:authenticator` module. HOTP (counter-based) items are out of scope and receive no next-code display.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
| ID | Requirement | Source | Notes |
|
||||
|----|-------------|--------|-------|
|
||||
| FR1 | Users can toggle "show next TOTP code" on or off via a setting | BWA-99 / user | Setting persists across sessions |
|
||||
| FR2 | When the setting is **enabled**, the next TOTP code is displayed below the current code for each TOTP item in the list | BWA-99 / user | Always visible — not threshold-triggered |
|
||||
| FR3 | When the setting is **disabled**, no next code is shown (list items appear as today) | BWA-99 / user | Default state is off |
|
||||
| FR4 | HOTP (counter-based) items never show a next code, regardless of the setting | User | Next code for HOTP requires advancing the counter — out of scope |
|
||||
| FR5 | The next code is computed as the TOTP code valid at `issueTime + periodSeconds` | BWA-99 / codebase | Pure computation, no counter mutation |
|
||||
| FR6 | The next code display uses the same formatting as the current code (spaces every 3 characters) | Codebase convention | Consistency with current code rendering |
|
||||
| FR7 | If next code generation fails (SDK error), the next code is silently omitted — no error shown | Default | TOTP math failure is extremely unlikely |
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
| ID | Requirement | Source | Notes |
|
||||
|----|-------------|--------|-------|
|
||||
| TR1 | Module scope: `:authenticator` only | Codebase | No changes to `:app` or shared modules |
|
||||
| TR2 | New boolean user setting `showNextTotpCode` persisted via SharedPreferences (`SettingsDiskSource`) | User | Default: `false` |
|
||||
| TR3 | `TotpCodeManagerImpl` must generate a `nextCode: String?` for TOTP items using `generateTotp(uri, issueTime + periodSeconds)` | Codebase | `null` for HOTP items |
|
||||
| TR4 | `VerificationCodeItem` data class must gain a `nextCode: String?` field | Codebase | `null` when HOTP or SDK call fails |
|
||||
| TR5 | `VerificationCodeDisplayItem` UI model must gain a `nextAuthCode: String?` field | Codebase | `null` when setting is off or item is HOTP |
|
||||
| TR6 | `VaultVerificationCodeItem` composable renders next code when `nextAuthCode != null` | Codebase | No label — code value only |
|
||||
| TR7 | Settings screen gains a new toggle row for this feature | User | Existing `BitwardenSwitch` pattern |
|
||||
| TR8 | No feature flag required | User | — |
|
||||
| TR9 | Setting change must reactively update the item list display without requiring app restart | User | Setting observed as `Flow<Boolean>` combined in `ItemListingViewModel` |
|
||||
| TR10 | F-Droid compatible — no Play Services dependency | Codebase | Pure local computation |
|
||||
|
||||
### Security Requirements
|
||||
|
||||
| ID | Requirement | Source | Notes |
|
||||
|----|-------------|--------|-------|
|
||||
| SR1 | Next TOTP code has the same sensitivity level as the current code — displayed in plaintext in the list view | Codebase | Already accepted pattern for current code |
|
||||
| SR2 | No new storage encryption required — next code is computed on demand, never persisted | Codebase | — |
|
||||
| SR3 | Setting value (`showNextTotpCode`) is non-sensitive — no encryption needed | User | SharedPreferences plaintext is acceptable |
|
||||
|
||||
### UX Requirements
|
||||
|
||||
| ID | Requirement | Source | Notes |
|
||||
|----|-------------|--------|-------|
|
||||
| UX1 | The next code appears inline below the current code within the list item composable | User / Figma | Figma design (node 4081-6476) is the layout authority |
|
||||
| UX2 | No label prefix for the next code — code value only | User | Distinguishable by position alone |
|
||||
| UX3 | Accessibility content description: `"Next code, [code]"` (e.g. `"Next code, 123 456"`) | User | Follows TalkBack pattern of current code |
|
||||
| UX4 | Settings toggle: label `"Show next code"`, sublabel `"See incoming codes in the list"` | User | In Settings screen |
|
||||
| UX5 | No analytics events for this feature | Default | Feature is passive display, no new user action |
|
||||
|
||||
### String Resources (`:ui` module `strings.xml`)
|
||||
|
||||
```xml
|
||||
<string name="show_next_code">Show next code</string>
|
||||
<string name="see_incoming_codes_in_the_list">See incoming codes in the list</string>
|
||||
```
|
||||
|
||||
### Open Items
|
||||
|
||||
| ID | Question | Assumed Default | Category |
|
||||
|----|----------|----------------|----------|
|
||||
| G5 | Exact Settings section to place the toggle | "Other" or existing display group — follow `SettingsScreen.kt` structure at time of implementation | UX |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Change Classification
|
||||
|
||||
**Enhancement** — Extending an existing feature. No new screens or navigation. All changes are additive modifications to existing files.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ SettingsScreen │ ← New BitwardenSwitch: "Show next code"
|
||||
└─────────────┬───────────────┘
|
||||
│ ShowNextTotpCodeToggle action
|
||||
┌─────────────▼───────────────┐
|
||||
│ SettingsViewModel │ ← Handles toggle, updates state + repository
|
||||
└─────────────┬───────────────┘
|
||||
│ write: settingsRepository.showNextTotpCode
|
||||
┌─────────────▼───────────────┐
|
||||
│ SettingsRepository │◄──────────────────────────────────────────────┐
|
||||
│ + showNextTotpCode: Boolean │ │
|
||||
│ + showNextTotpCodeStateFlow │ │ read: Flow<Boolean>
|
||||
└─────────────┬───────────────┘ │
|
||||
│ write │
|
||||
┌─────────────▼───────────────┐ ┌──────────────────────────────┐ │
|
||||
│ SettingsDiskSource │ │ ItemListingViewModel │──────┘
|
||||
│ + getShowNextTotpCode() │ │ observes showNextTotpCodeFlow │
|
||||
│ + storeShowNextTotpCode() │ └──────────────┬───────────────┘
|
||||
│ + getShowNextTotpCodeFlow()│ │ toDisplayItem(showNextCode = ...)
|
||||
└─────────────────────────────┘ ┌──────────────▼───────────────┐
|
||||
│ VerificationCodeItemExtensions│
|
||||
│ toDisplayItem(..., showNextCode)
|
||||
└──────────────┬───────────────┘
|
||||
│
|
||||
┌────────────────────────▼───────────────────────┐
|
||||
│ TotpCodeManagerImpl │
|
||||
│ On code generation: also call generateTotp( │
|
||||
│ uri, issueTime + periodSeconds │
|
||||
│ ) → VerificationCodeItem(nextCode = ...) │
|
||||
└────────────────────────┬───────────────────────┘
|
||||
│
|
||||
┌────────────────────────▼───────────────────────┐
|
||||
│ AuthenticatorSdkSource │
|
||||
│ generateTotp(uri, time: Instant) │
|
||||
└────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ VaultVerificationCodeItem │
|
||||
│ renders nextAuthCode when non-null (below authCode)│
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Design Decisions
|
||||
|
||||
| Decision | Resolution | Rationale |
|
||||
|----------|-----------|-----------|
|
||||
| Where to apply the setting | `toDisplayItem()` receives `showNextCode: Boolean`; `nextAuthCode` is `null` when off | Manager stays pure; ViewModel combines the setting flow with code flow |
|
||||
| When to generate next code | Only during code regeneration (on expiry), not on every 1s tick | Next code doesn't change until current code expires; avoids redundant SDK calls |
|
||||
| HOTP exclusion mechanism | Check `item.otpUri.contains("otpauth://hotp/", ignoreCase = true)` → `nextCode = null` | URI is the source of truth; no explicit type field on `AuthenticatorItem` |
|
||||
| `showNextTotpCode` in ViewModel | Added to `ItemListingState` top-level, observed via `settingsRepository.showNextTotpCodeStateFlow` | Reactive — setting change auto-rebuilds list items without restart |
|
||||
| Storage layer | SharedPreferences via `SettingsDiskSource` | Consistent with every other boolean setting in the authenticator |
|
||||
|
||||
### Pattern Anchors
|
||||
|
||||
1. `authenticator/.../datasource/disk/SettingsDiskSourceImpl.kt` — SharedPreferences boolean pattern (`getBoolean` / `putBoolean` / Flow)
|
||||
2. `authenticator/.../repository/SettingsRepositoryImpl.kt` — `StateFlow<Boolean>` wrapping disk source with `unconfinedScope`
|
||||
3. `authenticator/.../feature/settings/SettingsViewModel.kt` — toggle action handling and `mutableStateFlow.update { it.copy(...) }` pattern
|
||||
|
||||
### File Inventory
|
||||
|
||||
#### Files to Modify
|
||||
|
||||
| File Path | Change Description | Risk |
|
||||
|-----------|-------------------|------|
|
||||
| `authenticator/.../datasource/disk/SettingsDiskSource.kt` | Add `getShowNextTotpCode()`, `storeShowNextTotpCode()`, `getShowNextTotpCodeFlow()` | Low |
|
||||
| `authenticator/.../datasource/disk/SettingsDiskSourceImpl.kt` | Implement with SharedPreferences key `"showNextTotpCode"` | Low |
|
||||
| `authenticator/.../repository/SettingsRepository.kt` | Add `var showNextTotpCode: Boolean` + `showNextTotpCodeStateFlow: StateFlow<Boolean>` | Low |
|
||||
| `authenticator/.../repository/SettingsRepositoryImpl.kt` | Implement with disk source + `stateIn(unconfinedScope, SharingStarted.Lazily)` | Low |
|
||||
| `authenticator/.../manager/model/VerificationCodeItem.kt` | Add `val nextCode: String? = null` | Medium |
|
||||
| `authenticator/.../components/listitem/model/VerificationCodeDisplayItem.kt` | Add `val nextAuthCode: String? = null` | Medium |
|
||||
| `authenticator/.../manager/TotpCodeManagerImpl.kt` | On code regeneration: call `generateTotp(uri, nextPeriodInstant)` for non-HOTP items | Medium |
|
||||
| `authenticator/.../feature/util/VerificationCodeItemExtensions.kt` | Add `showNextCode: Boolean` param; map `nextCode` → `nextAuthCode` conditionally | Medium |
|
||||
| `authenticator/.../feature/itemlisting/ItemListingViewModel.kt` | Observe `showNextTotpCodeStateFlow`; add to `ItemListingState`; pass to `toDisplayItem()` | Medium |
|
||||
| `authenticator/.../feature/settings/SettingsViewModel.kt` | Add `ShowNextTotpCodeToggle` action, `showNextTotpCode` to state, handler | Low |
|
||||
| `authenticator/.../feature/settings/SettingsScreen.kt` | Add `BitwardenSwitch` with label/sublabel | Low |
|
||||
| `authenticator/.../components/listitem/VaultVerificationCodeItem.kt` | Add `nextAuthCode: String?` param; render below `authCode` when non-null | Low |
|
||||
| `ui/.../res/values/strings.xml` | Add `show_next_code`, `see_incoming_codes_in_the_list` | Low |
|
||||
| Test fakes (`FakeSettingsDiskSource`, `FakeSettingsRepository`, `FakeTotpCodeManager`) | Add new property stubs | Low |
|
||||
| Existing test files for each modified class | Update for new parameters/fields | Low |
|
||||
|
||||
### Implementation Phases
|
||||
|
||||
#### Phase 1: Setting Foundation
|
||||
|
||||
**Goal**: Persist and expose `showNextTotpCode` through all layers (disk → repository).
|
||||
|
||||
**Files**: `SettingsDiskSource.kt`, `SettingsDiskSourceImpl.kt`, `SettingsRepository.kt`, `SettingsRepositoryImpl.kt`, `FakeSettingsDiskSource.kt`, `FakeSettingsRepository.kt`
|
||||
|
||||
**Tasks**:
|
||||
1. Add `getShowNextTotpCode(): Boolean?`, `storeShowNextTotpCode(value: Boolean?)`, `getShowNextTotpCodeFlow(): Flow<Boolean?>` to `SettingsDiskSource` interface
|
||||
2. Implement in `SettingsDiskSourceImpl` using SharedPreferences key `"showNextTotpCode"`
|
||||
3. Add `var showNextTotpCode: Boolean` and `val showNextTotpCodeStateFlow: StateFlow<Boolean>` to `SettingsRepository`
|
||||
4. Implement in `SettingsRepositoryImpl` — getter defaults to `false`, StateFlow uses `unconfinedScope` + `SharingStarted.Lazily`
|
||||
5. Update fakes with matching stubs
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
./gradlew authenticator:testStandardDebugUnitTest --tests "*SettingsDiskSource*"
|
||||
./gradlew authenticator:testStandardDebugUnitTest --tests "*SettingsRepository*"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Phase 2: Data Model Extension
|
||||
|
||||
**Goal**: Add `nextCode` / `nextAuthCode` fields to the data and UI model layers.
|
||||
|
||||
**Files**: `VerificationCodeItem.kt`, `VerificationCodeDisplayItem.kt`
|
||||
|
||||
**Tasks**:
|
||||
1. Add `val nextCode: String? = null` to `VerificationCodeItem`
|
||||
2. Add `val nextAuthCode: String? = null` to `VerificationCodeDisplayItem` (verify `@Parcelize` compiles)
|
||||
3. Audit all `VerificationCodeItem(...)` constructors in test files; add `nextCode = null` where needed
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
./gradlew authenticator:compileStandardDebugUnitTestKotlin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Phase 3: TOTP Manager — Next Code Generation
|
||||
|
||||
**Goal**: `TotpCodeManagerImpl` generates next code for TOTP items during code refresh.
|
||||
|
||||
**Files**: `TotpCodeManagerImpl.kt`, `TotpCodeManagerTest.kt`
|
||||
|
||||
**Tasks**:
|
||||
1. In the code-regeneration branch (when `isExpired`), after a successful `generateTotp` result:
|
||||
- TOTP check: `!item.otpUri.contains("otpauth://hotp/", ignoreCase = true)`
|
||||
- If TOTP: call `generateTotp(item.otpUri, Instant.ofEpochMilli(clock.millis() + response.period * 1000L))`
|
||||
- Assign `nextCode = nextResponse.code` on success, `null` on failure or HOTP
|
||||
2. In the time-update-only branch, preserve existing `nextCode` via `.copy(timeLeftSeconds = ..., nextCode = verificationCodeItem.nextCode)`
|
||||
3. Write tests: TOTP item → non-null `nextCode`; HOTP item → null `nextCode`; SDK failure on next code → null `nextCode`
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
./gradlew authenticator:testStandardDebugUnitTest --tests "*TotpCodeManager*"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Phase 4: Conversion & ViewModel Wiring
|
||||
|
||||
**Goal**: Thread the setting and `nextCode` from repository through ViewModel to display items.
|
||||
|
||||
**Files**: `VerificationCodeItemExtensions.kt`, `ItemListingViewModel.kt`, `ItemListingViewModelTest.kt`
|
||||
|
||||
**Tasks**:
|
||||
1. Add `showNextCode: Boolean` parameter to `toDisplayItem()`; set `nextAuthCode = if (showNextCode) nextCode else null`
|
||||
2. Verify `settingsRepository` is injected in `ItemListingViewModel`; add if absent
|
||||
3. Add `showNextTotpCode: Boolean` to `ItemListingState`
|
||||
4. Combine `settingsRepository.showNextTotpCodeStateFlow` into the existing `combine(...)` block; update state when it changes
|
||||
5. Pass `showNextCode = state.showNextTotpCode` to all `toDisplayItem()` call sites
|
||||
6. Test: setting enabled → `nextAuthCode` non-null; setting disabled → `nextAuthCode` null
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
./gradlew authenticator:testStandardDebugUnitTest --tests "*ItemListingViewModel*"
|
||||
./gradlew authenticator:testStandardDebugUnitTest --tests "*VerificationCodeItemExtensions*"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Phase 5: Settings UI Toggle
|
||||
|
||||
**Goal**: Expose the setting to users in the Settings screen.
|
||||
|
||||
**Files**: `strings.xml`, `SettingsViewModel.kt`, `SettingsScreen.kt`, `SettingsViewModelTest.kt`
|
||||
|
||||
**Tasks**:
|
||||
1. Add `show_next_code` and `see_incoming_codes_in_the_list` to `strings.xml`
|
||||
2. Add `ShowNextTotpCodeToggle(val enabled: Boolean)` to `SettingsAction`
|
||||
3. Add `val showNextTotpCode: Boolean` to `SettingsState`; initialize from `settingsRepository.showNextTotpCode`
|
||||
4. Add handler: update `settingsRepository.showNextTotpCode` + `mutableStateFlow.update { it.copy(showNextTotpCode = action.enabled) }`
|
||||
5. Add `BitwardenSwitch` to `SettingsScreen` with `testTag("ShowNextTotpCodeSwitch")`
|
||||
6. Tests: toggle on stores `true`; toggle off stores `false`; state reflects repository initial value
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
./gradlew authenticator:testStandardDebugUnitTest --tests "*SettingsViewModel*"
|
||||
./gradlew authenticator:lintStandardDebug
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Phase 6: List Item UI
|
||||
|
||||
**Goal**: Render the next code in `VaultVerificationCodeItem` when present.
|
||||
|
||||
**Files**: `VaultVerificationCodeItem.kt`, `VaultVerificationCodeItemTest.kt`
|
||||
|
||||
**Tasks**:
|
||||
1. Add `nextAuthCode: String? = null` to both overloads of `VaultVerificationCodeItem`
|
||||
2. When `nextAuthCode != null`, render below `authCode` using same text style and 3-character spacing utility
|
||||
3. Apply `semantics { contentDescription = "Next code, $nextAuthCode" }` to the next code element
|
||||
4. Add test tag `"NextVerificationCode"` to the next code `Text`
|
||||
5. Pass `nextAuthCode = displayItem.nextAuthCode` in the `displayItem` overload
|
||||
6. Tests: next code rendered when non-null; absent when null; content description correct
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
./gradlew authenticator:testStandardDebugUnitTest --tests "*VaultVerificationCodeItem*"
|
||||
./gradlew authenticator:compileStandardDebugKotlin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Risk Assessment
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|-----------|--------|------------|
|
||||
| `@Parcelize` incompatibility with new nullable `String?` field | Low | Medium | Kotlin `@Parcelize` handles `String?` natively — verify at Phase 2 compile step |
|
||||
| Extra SDK call per TOTP item per period causes performance impact | Low | Low | One call per 30s per item; SDK is pure Rust math, negligible overhead |
|
||||
| HOTP detection via URI string check is fragile | Low | Low | OTP URI scheme is standardized; add explicit HOTP test case |
|
||||
| `settingsRepository` not yet injected in `ItemListingViewModel` | Unknown | Low | Verify in Phase 4 before assuming; add injection if absent |
|
||||
| Settings section placement conflicts with existing screen structure | Low | Low | Read `SettingsScreen.kt` in Phase 5 before inserting the toggle |
|
||||
|
||||
### Final Verification Checklist
|
||||
|
||||
```bash
|
||||
# Full authenticator unit tests
|
||||
./gradlew authenticator:testStandardDebugUnitTest
|
||||
|
||||
# Lint + detekt
|
||||
./gradlew authenticator:lintStandardDebug
|
||||
./gradlew detekt
|
||||
|
||||
# Build
|
||||
./gradlew authenticator:assembleStandardDebug
|
||||
```
|
||||
|
||||
**Manual scenarios**:
|
||||
1. Settings → "Show next code" toggle appears with correct label and sublabel
|
||||
2. Toggle ON → list items each show a second code below the current code
|
||||
3. Toggle OFF → next code disappears from all items
|
||||
4. Wait for code rollover → old next code becomes new current code; new next code appears
|
||||
5. HOTP items (if any) → no next code shown regardless of setting
|
||||
6. Kill app with "Don't keep activities" ON → restore → setting persists
|
||||
7. TalkBack: focus list item with next code → "Next code, [code]" announced
|
||||
|
||||
---
|
||||
|
||||
## Executing This Plan
|
||||
|
||||
To implement this plan, run:
|
||||
|
||||
/work-on-android BWA-99
|
||||
|
||||
Reference this design document during implementation for architecture decisions,
|
||||
file locations, and phase ordering.
|
||||
@@ -18,9 +18,11 @@ import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.isActive
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val ONE_SECOND_MILLISECOND = 1000L
|
||||
private const val HOTP_URI_PREFIX = "otpauth://hotp/"
|
||||
|
||||
/**
|
||||
* Primary implementation of [TotpCodeManager].
|
||||
@@ -106,16 +108,22 @@ class TotpCodeManagerImpl @Inject constructor(
|
||||
authenticatorSdkSource
|
||||
.generateTotp(item.otpUri, dateTime)
|
||||
.onSuccess { response ->
|
||||
val periodSeconds = response.period.toInt()
|
||||
val nextCode = generateNextCodeOrNull(
|
||||
item = item,
|
||||
issueTimeMillis = clock.millis(),
|
||||
periodSeconds = periodSeconds,
|
||||
)
|
||||
verificationCodeItem = VerificationCodeItem(
|
||||
code = response.code,
|
||||
periodSeconds = response.period.toInt(),
|
||||
timeLeftSeconds = response.period.toInt() -
|
||||
(time % response.period.toInt()),
|
||||
periodSeconds = periodSeconds,
|
||||
timeLeftSeconds = periodSeconds - (time % periodSeconds),
|
||||
issueTime = clock.millis(),
|
||||
id = item.cipherId,
|
||||
issuer = item.issuer,
|
||||
label = item.label,
|
||||
source = item.source,
|
||||
nextCode = nextCode,
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
@@ -136,6 +144,28 @@ class TotpCodeManagerImpl @Inject constructor(
|
||||
delay(ONE_SECOND_MILLISECOND)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the next TOTP code for the given [item] by generating a code valid at
|
||||
* `issueTimeMillis + periodSeconds`. Returns `null` for HOTP items or when the SDK call
|
||||
* fails. HOTP items are excluded because next-code generation requires advancing the counter.
|
||||
*/
|
||||
private suspend fun generateNextCodeOrNull(
|
||||
item: AuthenticatorItem,
|
||||
issueTimeMillis: Long,
|
||||
periodSeconds: Int,
|
||||
): String? {
|
||||
if (item.otpUri.startsWith(prefix = HOTP_URI_PREFIX, ignoreCase = true)) {
|
||||
return null
|
||||
}
|
||||
val nextInstant = Instant.ofEpochMilli(
|
||||
issueTimeMillis + (periodSeconds * ONE_SECOND_MILLISECOND),
|
||||
)
|
||||
return authenticatorSdkSource
|
||||
.generateTotp(totp = item.otpUri, time = nextInstant)
|
||||
.getOrNull()
|
||||
?.code
|
||||
}
|
||||
}
|
||||
|
||||
private fun VerificationCodeItem.isExpired(clock: Clock): Boolean {
|
||||
|
||||
@@ -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 TOTP code that will become valid when the current one expires, or
|
||||
* `null` if not available (HOTP items, SDK failures, or items that have not yet computed it).
|
||||
*/
|
||||
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.
|
||||
|
||||
@@ -145,4 +145,19 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
|
||||
* Stores whether [isScreenCaptureAllowed].
|
||||
*/
|
||||
fun storeScreenCaptureAllowed(isScreenCaptureAllowed: Boolean?)
|
||||
|
||||
/**
|
||||
* Gets whether the user has enabled showing the next TOTP code in the item list.
|
||||
*/
|
||||
fun getShowNextTotpCode(): Boolean?
|
||||
|
||||
/**
|
||||
* Stores whether the user has enabled showing the next TOTP code in the item list.
|
||||
*/
|
||||
fun storeShowNextTotpCode(value: Boolean?)
|
||||
|
||||
/**
|
||||
* Emits updates that track [getShowNextTotpCode].
|
||||
*/
|
||||
fun getShowNextTotpCodeFlow(): Flow<Boolean?>
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ private const val HAS_USER_DISMISSED_SYNC_WITH_BITWARDEN_KEY =
|
||||
"hasUserDismissedSyncWithBitwardenCard"
|
||||
private const val PREVIOUSLY_SYNCED_BITWARDEN_ACCOUNT_IDS_KEY =
|
||||
"previouslySyncedBitwardenAccountIds"
|
||||
private const val SHOW_NEXT_TOTP_CODE_KEY = "showNextTotpCode"
|
||||
private const val DEFAULT_ALERT_THRESHOLD_SECONDS = 7
|
||||
|
||||
/**
|
||||
@@ -58,6 +59,8 @@ class SettingsDiskSourceImpl(
|
||||
|
||||
private val mutableDynamicColorsFlow = bufferedMutableSharedFlow<Boolean?>()
|
||||
|
||||
private val mutableShowNextTotpCodeFlow = bufferedMutableSharedFlow<Boolean?>()
|
||||
|
||||
override var appLanguage: AppLanguage?
|
||||
get() = getString(key = APP_LANGUAGE_KEY)
|
||||
?.let { storedValue ->
|
||||
@@ -226,4 +229,14 @@ class SettingsDiskSourceImpl(
|
||||
)
|
||||
mutableScreenCaptureAllowedFlow.tryEmit(isScreenCaptureAllowed)
|
||||
}
|
||||
|
||||
override fun getShowNextTotpCode(): Boolean? = getBoolean(key = SHOW_NEXT_TOTP_CODE_KEY)
|
||||
|
||||
override fun storeShowNextTotpCode(value: Boolean?) {
|
||||
putBoolean(key = SHOW_NEXT_TOTP_CODE_KEY, value = value)
|
||||
mutableShowNextTotpCodeFlow.tryEmit(value)
|
||||
}
|
||||
|
||||
override fun getShowNextTotpCodeFlow(): Flow<Boolean?> = mutableShowNextTotpCodeFlow
|
||||
.onSubscription { emit(getShowNextTotpCode()) }
|
||||
}
|
||||
|
||||
@@ -112,4 +112,14 @@ interface SettingsRepository : FlightRecorderManager {
|
||||
* Gets updates for the [AppTimeout].
|
||||
*/
|
||||
val appTimeoutStateFlow: StateFlow<AppTimeout>
|
||||
|
||||
/**
|
||||
* Whether the next TOTP code should be displayed below the current code in the item list.
|
||||
*/
|
||||
var showNextTotpCode: Boolean
|
||||
|
||||
/**
|
||||
* Tracks changes to the [showNextTotpCode] value.
|
||||
*/
|
||||
val showNextTotpCodeStateFlow: StateFlow<Boolean>
|
||||
}
|
||||
|
||||
@@ -158,6 +158,22 @@ class SettingsRepositoryImpl(
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = settingsDiskSource.appTimeoutInMinutes.toAppTimeout(),
|
||||
)
|
||||
|
||||
override var showNextTotpCode: Boolean
|
||||
get() = settingsDiskSource.getShowNextTotpCode() ?: false
|
||||
set(value) {
|
||||
settingsDiskSource.storeShowNextTotpCode(value = value)
|
||||
}
|
||||
|
||||
override val showNextTotpCodeStateFlow: StateFlow<Boolean>
|
||||
get() = settingsDiskSource
|
||||
.getShowNextTotpCodeFlow()
|
||||
.map { it ?: false }
|
||||
.stateIn(
|
||||
scope = unconfinedScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = showNextTotpCode,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,11 +64,16 @@ class ItemListingViewModel @Inject constructor(
|
||||
) : BaseViewModel<ItemListingState, ItemListingEvent, ItemListingAction>(
|
||||
initialState = ItemListingState(
|
||||
alertThresholdSeconds = settingsRepository.authenticatorAlertThresholdSeconds,
|
||||
showNextTotpCode = settingsRepository.showNextTotpCode,
|
||||
viewState = ItemListingState.ViewState.Loading,
|
||||
dialog = null,
|
||||
),
|
||||
) {
|
||||
|
||||
// Cached once so that handleShowNextTotpCodeReceive can read `.value` from the
|
||||
// already-subscribed flow rather than creating a new unsubscribed instance.
|
||||
private val localCodesFlow = authenticatorRepository.getLocalVerificationCodesFlow()
|
||||
|
||||
init {
|
||||
settingsRepository
|
||||
.authenticatorAlertThresholdSecondsFlow
|
||||
@@ -76,8 +81,14 @@ class ItemListingViewModel @Inject constructor(
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
settingsRepository
|
||||
.showNextTotpCodeStateFlow
|
||||
.map { ItemListingAction.Internal.ShowNextTotpCodeReceive(it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
combine(
|
||||
flow = authenticatorRepository.getLocalVerificationCodesFlow(),
|
||||
flow = localCodesFlow,
|
||||
flow2 = authenticatorRepository.sharedCodesStateFlow,
|
||||
ItemListingAction.Internal::AuthCodesUpdated,
|
||||
)
|
||||
@@ -244,6 +255,10 @@ class ItemListingViewModel @Inject constructor(
|
||||
handleAlertThresholdSecondsReceive(internalAction)
|
||||
}
|
||||
|
||||
is ItemListingAction.Internal.ShowNextTotpCodeReceive -> {
|
||||
handleShowNextTotpCodeReceive(internalAction)
|
||||
}
|
||||
|
||||
is ItemListingAction.Internal.TotpCodeReceive -> {
|
||||
handleTotpCodeReceive(internalAction)
|
||||
}
|
||||
@@ -450,6 +465,21 @@ class ItemListingViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleShowNextTotpCodeReceive(
|
||||
action: ItemListingAction.Internal.ShowNextTotpCodeReceive,
|
||||
) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(showNextTotpCode = action.showNextTotpCode)
|
||||
}
|
||||
// Immediately re-derive the displayed list using the cached subscribed flow so that
|
||||
// next-code visibility changes take effect without waiting for the next 1-second tick.
|
||||
val codesUpdate = ItemListingAction.Internal.AuthCodesUpdated(
|
||||
localCodes = localCodesFlow.value,
|
||||
sharedCodesState = authenticatorRepository.sharedCodesStateFlow.value,
|
||||
)
|
||||
handleAuthenticatorDataReceive(codesUpdate)
|
||||
}
|
||||
|
||||
private fun handleDialogDismiss() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialog = null)
|
||||
@@ -508,6 +538,7 @@ class ItemListingViewModel @Inject constructor(
|
||||
.sharedCodesStateFlow
|
||||
.value,
|
||||
showOverflow = true,
|
||||
showNextCode = state.showNextTotpCode,
|
||||
)
|
||||
}
|
||||
.sortAlphabetically()
|
||||
@@ -521,6 +552,7 @@ class ItemListingViewModel @Inject constructor(
|
||||
.sharedCodesStateFlow
|
||||
.value,
|
||||
showOverflow = true,
|
||||
showNextCode = state.showNextTotpCode,
|
||||
)
|
||||
}
|
||||
.sortAlphabetically()
|
||||
@@ -712,6 +744,7 @@ const val ISSUER = "issuer"
|
||||
@Parcelize
|
||||
data class ItemListingState(
|
||||
val alertThresholdSeconds: Int,
|
||||
val showNextTotpCode: Boolean,
|
||||
val viewState: ViewState,
|
||||
val dialog: DialogState?,
|
||||
) : Parcelable {
|
||||
@@ -991,6 +1024,13 @@ sealed class ItemListingAction {
|
||||
val thresholdSeconds: Int,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates that the show-next-TOTP-code setting has changed.
|
||||
*/
|
||||
data class ShowNextTotpCodeReceive(
|
||||
val showNextTotpCode: Boolean,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates a new TOTP code scan result has been received.
|
||||
*/
|
||||
|
||||
@@ -5,13 +5,21 @@ import com.bitwarden.authenticator.data.authenticator.repository.model.Authentic
|
||||
import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState
|
||||
import com.bitwarden.authenticator.ui.platform.components.listitem.model.VerificationCodeDisplayItem
|
||||
|
||||
private const val NEXT_CODE_SHOW_THRESHOLD_SECONDS = 10
|
||||
|
||||
/**
|
||||
* Converts [VerificationCodeItem] to a [VerificationCodeDisplayItem].
|
||||
*
|
||||
* @param showNextCode When `true`, the upcoming code is mapped to
|
||||
* [VerificationCodeDisplayItem.nextAuthCode] for items within the last
|
||||
* [NEXT_CODE_SHOW_THRESHOLD_SECONDS] seconds of their validity period. When `false`,
|
||||
* [VerificationCodeDisplayItem.nextAuthCode] is always `null`.
|
||||
*/
|
||||
fun VerificationCodeItem.toDisplayItem(
|
||||
alertThresholdSeconds: Int,
|
||||
sharedVerificationCodesState: SharedVerificationCodesState,
|
||||
showOverflow: Boolean,
|
||||
showNextCode: Boolean = false,
|
||||
): VerificationCodeDisplayItem = VerificationCodeDisplayItem(
|
||||
id = id,
|
||||
title = issuer ?: label ?: "--",
|
||||
@@ -25,6 +33,11 @@ fun VerificationCodeItem.toDisplayItem(
|
||||
periodSeconds = periodSeconds,
|
||||
alertThresholdSeconds = alertThresholdSeconds,
|
||||
authCode = code,
|
||||
nextAuthCode = if (showNextCode && timeLeftSeconds <= NEXT_CODE_SHOW_THRESHOLD_SECONDS) {
|
||||
nextCode
|
||||
} else {
|
||||
null
|
||||
},
|
||||
showOverflow = showOverflow,
|
||||
favorite = (source as? AuthenticatorItem.Source.Local)?.isFavorite ?: false,
|
||||
showMoveToBitwarden = when (source) {
|
||||
|
||||
@@ -12,6 +12,8 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -59,6 +61,7 @@ fun VaultVerificationCodeItem(
|
||||
onDropdownMenuClick = onDropdownMenuClick,
|
||||
showOverflow = displayItem.showOverflow,
|
||||
showMoveToBitwarden = displayItem.showMoveToBitwarden,
|
||||
nextAuthCode = displayItem.nextAuthCode,
|
||||
cardStyle = cardStyle,
|
||||
modifier = modifier,
|
||||
)
|
||||
@@ -78,6 +81,8 @@ fun VaultVerificationCodeItem(
|
||||
* @param onDropdownMenuClick A lambda function invoked when a dropdown menu action is clicked.
|
||||
* @param showOverflow Whether overflow menu should be available or not.
|
||||
* @param showMoveToBitwarden Whether the option to move the item to Bitwarden is displayed.
|
||||
* @param nextAuthCode The upcoming authentication code, displayed below the current code when
|
||||
* non-null.
|
||||
* @param cardStyle The card style to be applied to this item.
|
||||
* @param modifier The modifier for the item.
|
||||
*/
|
||||
@@ -97,6 +102,7 @@ fun VaultVerificationCodeItem(
|
||||
showMoveToBitwarden: Boolean,
|
||||
cardStyle: CardStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
nextAuthCode: String? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
@@ -152,14 +158,34 @@ fun VaultVerificationCodeItem(
|
||||
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,
|
||||
)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.testTag(tag = "AuthCode"),
|
||||
text = authCode.formatAsAuthCode(),
|
||||
style = BitwardenTheme.typography.sensitiveInfoSmall,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
)
|
||||
if (nextAuthCode != null) {
|
||||
val formattedNextAuthCode = nextAuthCode.formatAsAuthCode()
|
||||
val nextCodeContentDescription = stringResource(
|
||||
id = BitwardenString.next_code_x,
|
||||
formattedNextAuthCode,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.testTag(tag = "NextVerificationCode")
|
||||
.semantics {
|
||||
contentDescription = nextCodeContentDescription
|
||||
},
|
||||
text = formattedNextAuthCode,
|
||||
style = BitwardenTheme.typography.sensitiveInfoSmall,
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showOverflow) {
|
||||
BitwardenOverflowActionItem(
|
||||
@@ -200,6 +226,15 @@ fun VaultVerificationCodeItem(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an authenticator code by inserting a space every 3 characters for readability.
|
||||
*/
|
||||
@Suppress("MagicNumber")
|
||||
private fun String.formatAsAuthCode(): String =
|
||||
this
|
||||
.chunked(size = 3) { it.padEnd(length = 3, padChar = ' ') }
|
||||
.joinToString(separator = " ")
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
|
||||
@@ -24,4 +24,5 @@ data class VerificationCodeDisplayItem(
|
||||
val favorite: Boolean,
|
||||
val showOverflow: Boolean,
|
||||
val showMoveToBitwarden: Boolean,
|
||||
val nextAuthCode: String? = null,
|
||||
) : Parcelable
|
||||
|
||||
@@ -234,6 +234,22 @@ fun SettingsScreen(
|
||||
viewModel.trySendAction(SettingsAction.AppearanceChange.DynamicColorChange(it))
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
BitwardenSwitch(
|
||||
label = stringResource(id = BitwardenString.show_next_code),
|
||||
supportingText = stringResource(
|
||||
id = BitwardenString.see_incoming_codes_in_the_list,
|
||||
),
|
||||
isChecked = state.showNextTotpCode,
|
||||
onCheckedChange = {
|
||||
viewModel.trySendAction(SettingsAction.ShowNextTotpCodeToggle(it))
|
||||
},
|
||||
cardStyle = CardStyle.Full,
|
||||
modifier = Modifier
|
||||
.testTag(tag = "ShowNextTotpCodeSwitch")
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HelpSettings(
|
||||
onTutorialClick = {
|
||||
|
||||
@@ -72,6 +72,7 @@ class SettingsViewModel @Inject constructor(
|
||||
isScreenCaptureAllowed = settingsRepository.isScreenCaptureAllowed,
|
||||
isDynamicColorsEnabled = settingsRepository.isDynamicColorsEnabled,
|
||||
appTimeout = settingsRepository.appTimeoutState,
|
||||
showNextTotpCode = settingsRepository.showNextTotpCode,
|
||||
),
|
||||
) {
|
||||
|
||||
@@ -134,10 +135,21 @@ class SettingsViewModel @Inject constructor(
|
||||
handleBiometricSupportChanged(action)
|
||||
}
|
||||
|
||||
is SettingsAction.ShowNextTotpCodeToggle -> {
|
||||
handleShowNextTotpCodeToggle(action)
|
||||
}
|
||||
|
||||
is SettingsAction.Internal -> handleInternalAction(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleShowNextTotpCodeToggle(
|
||||
action: SettingsAction.ShowNextTotpCodeToggle,
|
||||
) {
|
||||
settingsRepository.showNextTotpCode = action.enabled
|
||||
mutableStateFlow.update { it.copy(showNextTotpCode = action.enabled) }
|
||||
}
|
||||
|
||||
private fun handleInternalAction(action: SettingsAction.Internal) {
|
||||
when (action) {
|
||||
is SettingsAction.Internal.BiometricsKeyResultReceive -> {
|
||||
@@ -465,6 +477,7 @@ class SettingsViewModel @Inject constructor(
|
||||
isScreenCaptureAllowed: Boolean,
|
||||
isDynamicColorsEnabled: Boolean,
|
||||
appTimeout: AppTimeout,
|
||||
showNextTotpCode: Boolean,
|
||||
): SettingsState {
|
||||
val currentYear = Year.now(clock)
|
||||
val copyrightInfo = "© Bitwarden Inc. 2015-$currentYear".asText()
|
||||
@@ -494,6 +507,7 @@ class SettingsViewModel @Inject constructor(
|
||||
allowScreenCapture = isScreenCaptureAllowed,
|
||||
hasBiometricsSupport = true,
|
||||
appTimeout = appTimeout,
|
||||
showNextTotpCode = showNextTotpCode,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -516,6 +530,7 @@ data class SettingsState(
|
||||
val copyrightInfo: Text,
|
||||
val allowScreenCapture: Boolean,
|
||||
val appTimeout: AppTimeout,
|
||||
val showNextTotpCode: Boolean,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
@@ -639,6 +654,11 @@ sealed class SettingsAction {
|
||||
*/
|
||||
data class BiometricSupportChanged(val isBiometricsSupported: Boolean) : SettingsAction()
|
||||
|
||||
/**
|
||||
* Indicates the user toggled the "Show next code" switch.
|
||||
*/
|
||||
data class ShowNextTotpCodeToggle(val enabled: Boolean) : SettingsAction()
|
||||
|
||||
/**
|
||||
* Models actions for the Security section of settings.
|
||||
*/
|
||||
|
||||
@@ -48,16 +48,82 @@ class TotpCodeManagerTest {
|
||||
createMockAuthenticatorItem(number = 1, otpUri = totp),
|
||||
)
|
||||
val code = "123456"
|
||||
val nextCode = "654321"
|
||||
val totpResponse = TotpResponse(code = code, period = 30u)
|
||||
val nextTotpResponse = TotpResponse(code = nextCode, period = 30u)
|
||||
coEvery {
|
||||
authenticatorSdkSource.generateTotp(totp = totp, time = clock.instant())
|
||||
} returns totpResponse.asSuccess()
|
||||
coEvery {
|
||||
authenticatorSdkSource.generateTotp(
|
||||
totp = totp,
|
||||
time = Instant.ofEpochMilli(clock.millis() + THIRTY_SECONDS_MILLIS),
|
||||
)
|
||||
} returns nextTotpResponse.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 set nextCode to null for HOTP items`() = runTest {
|
||||
val totp = "otpauth://hotp/Issuer:user@example.com?secret=ABC&counter=1"
|
||||
val authenticatorItems = listOf(
|
||||
createMockAuthenticatorItem(number = 1, otpUri = totp),
|
||||
)
|
||||
val code = "123456"
|
||||
val totpResponse = TotpResponse(code = code, period = 30u)
|
||||
coEvery {
|
||||
authenticatorSdkSource.generateTotp(totp = totp, time = clock.instant())
|
||||
} returns totpResponse.asSuccess()
|
||||
|
||||
val expected = createMockVerificationCodeItem(
|
||||
number = 1,
|
||||
code = code,
|
||||
issueTime = clock.instant().toEpochMilli(),
|
||||
timeLeftSeconds = 30,
|
||||
nextCode = null,
|
||||
)
|
||||
|
||||
manager.getTotpCodesFlow(authenticatorItems).test {
|
||||
assertEquals(listOf(expected), awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getTotpCodesFlow should set nextCode to null when next code generation fails`() =
|
||||
runTest {
|
||||
val totp = "otpUri"
|
||||
val authenticatorItems = listOf(
|
||||
createMockAuthenticatorItem(number = 1, otpUri = totp),
|
||||
)
|
||||
val code = "123456"
|
||||
val totpResponse = TotpResponse(code = code, period = 30u)
|
||||
coEvery {
|
||||
authenticatorSdkSource.generateTotp(totp = totp, time = clock.instant())
|
||||
} returns totpResponse.asSuccess()
|
||||
coEvery {
|
||||
authenticatorSdkSource.generateTotp(
|
||||
totp = totp,
|
||||
time = Instant.ofEpochMilli(clock.millis() + THIRTY_SECONDS_MILLIS),
|
||||
)
|
||||
} returns Exception().asFailure()
|
||||
|
||||
val expected = createMockVerificationCodeItem(
|
||||
number = 1,
|
||||
code = code,
|
||||
issueTime = clock.instant().toEpochMilli(),
|
||||
timeLeftSeconds = 30,
|
||||
nextCode = null,
|
||||
)
|
||||
|
||||
manager.getTotpCodesFlow(authenticatorItems).test {
|
||||
@@ -84,3 +150,5 @@ class TotpCodeManagerTest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val THIRTY_SECONDS_MILLIS: Long = 30_000L
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -168,6 +168,39 @@ class SettingsRepositoryTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showNextTotpCode should default to false when disk source is null`() {
|
||||
every { settingsDiskSource.getShowNextTotpCode() } returns null
|
||||
assertFalse(settingsRepository.showNextTotpCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showNextTotpCode should pull from and update SettingsDiskSource`() {
|
||||
every { settingsDiskSource.getShowNextTotpCode() } returns true
|
||||
assertTrue(settingsRepository.showNextTotpCode)
|
||||
verify { settingsDiskSource.getShowNextTotpCode() }
|
||||
|
||||
every { settingsDiskSource.storeShowNextTotpCode(value = true) } just runs
|
||||
settingsRepository.showNextTotpCode = true
|
||||
verify { settingsDiskSource.storeShowNextTotpCode(value = true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showNextTotpCodeStateFlow should match SettingsDiskSource`() = runTest {
|
||||
val mutableFlow = bufferedMutableSharedFlow<Boolean?>()
|
||||
every { settingsDiskSource.getShowNextTotpCodeFlow() } returns mutableFlow
|
||||
every { settingsDiskSource.getShowNextTotpCode() } returns null
|
||||
|
||||
settingsRepository.showNextTotpCodeStateFlow.test {
|
||||
assertFalse(awaitItem())
|
||||
mutableFlow.emit(true)
|
||||
assertTrue(awaitItem())
|
||||
mutableFlow.emit(false)
|
||||
assertFalse(awaitItem())
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `previouslySyncedBitwardenAccountIds should pull from and update SettingsDiskSource`() {
|
||||
// Reading from repository should read from disk source:
|
||||
|
||||
@@ -575,6 +575,7 @@ private val SHARED_ACCOUNTS_SECTION = SharedCodesDisplayState.SharedCodesAccount
|
||||
|
||||
private val DEFAULT_STATE = ItemListingState(
|
||||
alertThresholdSeconds = ALERT_THRESHOLD,
|
||||
showNextTotpCode = false,
|
||||
viewState = ItemListingState.ViewState.NoItems(
|
||||
actionCard = null,
|
||||
),
|
||||
|
||||
@@ -42,6 +42,7 @@ class ItemListingViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val mutableAuthenticatorAlertThresholdFlow =
|
||||
MutableStateFlow(AUTHENTICATOR_ALERT_SECONDS)
|
||||
private val mutableShowNextTotpCodeFlow = MutableStateFlow(false)
|
||||
private val mutableVerificationCodesFlow =
|
||||
MutableStateFlow<DataState<List<VerificationCodeItem>>>(DataState.Loading)
|
||||
private val mutableSharedCodesFlow =
|
||||
@@ -64,6 +65,8 @@ class ItemListingViewModelTest : BaseViewModelTest() {
|
||||
every {
|
||||
authenticatorAlertThresholdSecondsFlow
|
||||
} returns mutableAuthenticatorAlertThresholdFlow
|
||||
every { showNextTotpCode } answers { mutableShowNextTotpCodeFlow.value }
|
||||
every { showNextTotpCodeStateFlow } returns mutableShowNextTotpCodeFlow
|
||||
every { hasUserDismissedDownloadBitwardenCard } returns false
|
||||
}
|
||||
private val mutableSnackbarFlow = bufferedMutableSharedFlow<BitwardenSnackbarData>()
|
||||
@@ -565,6 +568,49 @@ class ItemListingViewModelTest : BaseViewModelTest() {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initialState should reflect repository showNextTotpCode value`() {
|
||||
mutableShowNextTotpCodeFlow.value = true
|
||||
every { settingsRepository.showNextTotpCode } returns true
|
||||
val viewModel = createViewModel()
|
||||
assertEquals(true, viewModel.stateFlow.value.showNextTotpCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `when showNextTotpCodeStateFlow updates, content list items reflect the new setting`() {
|
||||
val verificationItem = VerificationCodeItem(
|
||||
code = "123456",
|
||||
periodSeconds = 60,
|
||||
timeLeftSeconds = 5,
|
||||
issueTime = 35L,
|
||||
id = "1",
|
||||
issuer = "issuer",
|
||||
label = "accountName",
|
||||
source = AuthenticatorItem.Source.Local(isFavorite = false),
|
||||
nextCode = "654321",
|
||||
)
|
||||
mutableVerificationCodesFlow.value = DataState.Loaded(listOf(verificationItem))
|
||||
mutableSharedCodesFlow.value =
|
||||
SharedVerificationCodesState.Success(emptyList())
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
// Initially show-next is false: nextAuthCode is null on display item.
|
||||
val initialContent =
|
||||
viewModel.stateFlow.value.viewState as ItemListingState.ViewState.Content
|
||||
assertEquals(null, initialContent.itemList.first().nextAuthCode)
|
||||
assertEquals(false, viewModel.stateFlow.value.showNextTotpCode)
|
||||
|
||||
// Toggling on emits a new state with showNextTotpCode = true and nextAuthCode populated.
|
||||
mutableShowNextTotpCodeFlow.value = true
|
||||
|
||||
val updatedContent =
|
||||
viewModel.stateFlow.value.viewState as ItemListingState.ViewState.Content
|
||||
assertEquals("654321", updatedContent.itemList.first().nextAuthCode)
|
||||
assertEquals(true, viewModel.stateFlow.value.showNextTotpCode)
|
||||
}
|
||||
|
||||
private fun createViewModel(): ItemListingViewModel = ItemListingViewModel(
|
||||
authenticatorRepository = authenticatorRepository,
|
||||
authenticatorBridgeManager = authenticatorBridgeManager,
|
||||
@@ -578,6 +624,7 @@ class ItemListingViewModelTest : BaseViewModelTest() {
|
||||
private const val AUTHENTICATOR_ALERT_SECONDS = 7
|
||||
private val DEFAULT_STATE = ItemListingState(
|
||||
alertThresholdSeconds = AUTHENTICATOR_ALERT_SECONDS,
|
||||
showNextTotpCode = false,
|
||||
viewState = ItemListingState.ViewState.Loading,
|
||||
dialog = null,
|
||||
)
|
||||
|
||||
@@ -145,6 +145,74 @@ class VerificationCodeItemExtensionsTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `toDisplayItem should set nextAuthCode when showNextCode is true and timeLeftSeconds is within threshold`() {
|
||||
val item = createMockVerificationCodeItem(number = 1, nextCode = "654321", timeLeftSeconds = 5)
|
||||
val result = item.toDisplayItem(
|
||||
alertThresholdSeconds = 7,
|
||||
sharedVerificationCodesState = SharedVerificationCodesState.Error,
|
||||
showOverflow = true,
|
||||
showNextCode = true,
|
||||
)
|
||||
assertEquals("654321", result.nextAuthCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `toDisplayItem should set nextAuthCode when showNextCode is true and timeLeftSeconds equals threshold`() {
|
||||
val item = createMockVerificationCodeItem(number = 1, nextCode = "654321", timeLeftSeconds = 10)
|
||||
val result = item.toDisplayItem(
|
||||
alertThresholdSeconds = 7,
|
||||
sharedVerificationCodesState = SharedVerificationCodesState.Error,
|
||||
showOverflow = true,
|
||||
showNextCode = true,
|
||||
)
|
||||
assertEquals("654321", result.nextAuthCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `toDisplayItem should set nextAuthCode to null when showNextCode is true but timeLeftSeconds exceeds threshold`() {
|
||||
val item = createMockVerificationCodeItem(number = 1, nextCode = "654321", timeLeftSeconds = 11)
|
||||
val result = item.toDisplayItem(
|
||||
alertThresholdSeconds = 7,
|
||||
sharedVerificationCodesState = SharedVerificationCodesState.Error,
|
||||
showOverflow = true,
|
||||
showNextCode = true,
|
||||
)
|
||||
assertEquals(null, result.nextAuthCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toDisplayItem should set nextAuthCode to null when showNextCode is false`() {
|
||||
val item = createMockVerificationCodeItem(
|
||||
number = 1,
|
||||
nextCode = "654321",
|
||||
timeLeftSeconds = 5,
|
||||
)
|
||||
val result = item.toDisplayItem(
|
||||
alertThresholdSeconds = 7,
|
||||
sharedVerificationCodesState = SharedVerificationCodesState.Error,
|
||||
showOverflow = true,
|
||||
showNextCode = false,
|
||||
)
|
||||
assertEquals(null, result.nextAuthCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `toDisplayItem should set nextAuthCode to null when showNextCode true but nextCode is null`() {
|
||||
val item = createMockVerificationCodeItem(number = 1, nextCode = null, timeLeftSeconds = 5)
|
||||
val result = item.toDisplayItem(
|
||||
alertThresholdSeconds = 7,
|
||||
sharedVerificationCodesState = SharedVerificationCodesState.Error,
|
||||
showOverflow = true,
|
||||
showNextCode = true,
|
||||
)
|
||||
assertEquals(null, result.nextAuthCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toDisplayItem should map Shared items correctly`() {
|
||||
val alertThresholdSeconds = 7
|
||||
|
||||
@@ -378,4 +378,5 @@ private val DEFAULT_STATE = SettingsState(
|
||||
allowScreenCapture = false,
|
||||
hasBiometricsSupport = true,
|
||||
appTimeout = AppTimeout.OnAppRestart,
|
||||
showNextTotpCode = false,
|
||||
)
|
||||
|
||||
@@ -77,6 +77,8 @@ class SettingsViewModelTest : BaseViewModelTest() {
|
||||
every { appTimeoutState = any() } just runs
|
||||
every { appTimeoutStateFlow } returns mutableAppTimeoutStateFlow
|
||||
every { appTimeoutState } answers { mutableAppTimeoutStateFlow.value }
|
||||
every { showNextTotpCode } returns false
|
||||
every { showNextTotpCode = any() } just runs
|
||||
}
|
||||
private val clipboardManager: BitwardenClipboardManager = mockk()
|
||||
private val mutableSnackbarFlow = bufferedMutableSharedFlow<BitwardenSnackbarData>()
|
||||
@@ -410,6 +412,37 @@ class SettingsViewModelTest : BaseViewModelTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on ShowNextTotpCodeToggle enabled should update repository and state`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
viewModel.trySendAction(SettingsAction.ShowNextTotpCodeToggle(enabled = true))
|
||||
assertEquals(DEFAULT_STATE.copy(showNextTotpCode = true), awaitItem())
|
||||
}
|
||||
verify { settingsRepository.showNextTotpCode = true }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on ShowNextTotpCodeToggle disabled should update repository and state`() = runTest {
|
||||
val viewModel = createViewModel(
|
||||
savedState = DEFAULT_STATE.copy(showNextTotpCode = true),
|
||||
)
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE.copy(showNextTotpCode = true), awaitItem())
|
||||
viewModel.trySendAction(SettingsAction.ShowNextTotpCodeToggle(enabled = false))
|
||||
assertEquals(DEFAULT_STATE.copy(showNextTotpCode = false), awaitItem())
|
||||
}
|
||||
verify { settingsRepository.showNextTotpCode = false }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initialState should reflect repository showNextTotpCode value`() {
|
||||
every { settingsRepository.showNextTotpCode } returns true
|
||||
val viewModel = createViewModel(savedState = null)
|
||||
assertEquals(true, viewModel.stateFlow.value.showNextTotpCode)
|
||||
}
|
||||
|
||||
private fun createViewModel(
|
||||
savedState: SettingsState? = DEFAULT_STATE,
|
||||
): SettingsViewModel = SettingsViewModel(
|
||||
@@ -452,4 +485,5 @@ private val DEFAULT_STATE = SettingsState(
|
||||
allowScreenCapture = false,
|
||||
hasBiometricsSupport = true,
|
||||
appTimeout = AppTimeout.OnAppRestart,
|
||||
showNextTotpCode = false,
|
||||
)
|
||||
|
||||
@@ -278,6 +278,9 @@ Scanning will happen automatically.</string>
|
||||
<string name="show_website_icons">Show website icons</string>
|
||||
<string name="show_website_icons_help">Show website icons help</string>
|
||||
<string name="show_website_icons_description">Show a recognizable image next to each login</string>
|
||||
<string name="show_next_code">Show next code</string>
|
||||
<string name="see_incoming_codes_in_the_list">See incoming codes in the list</string>
|
||||
<string name="next_code_x">Next code, %1$s</string>
|
||||
<string name="icons_url">Icons server URL</string>
|
||||
<string name="vault_is_locked">Vault is locked</string>
|
||||
<string name="go_to_my_vault">Go to my vault</string>
|
||||
|
||||
Reference in New Issue
Block a user