Skip to content

Conversation

@tajchert
Copy link

@tajchert tajchert commented Dec 7, 2025

Disclaimer: This PR was created with AI assisted agent (Claude Code) but manually checked myself to my best knowledge and this repository standards. If you feel this is against contribution guidelines (I haven't found anything related to such) feel free to disregard this PR. I honestly believe this is valid PR, and I tested it manually as well.

This fix OTP code freeze when returning to Authenticator app from background - issue #6244
when occasionally user can experience frozen TOTP codes and countdown timers when returning to the Authenticator app from background. The codes remain static and don't update until the screen is manually refreshed. This changes flow implementation in AUthenticator app (Flow) to one that is Password Manager (StateFlow).

Root Cause

The bug stems from a flow lifecycle mismatch in TotpCodeManagerImpl. The TOTP code update mechanism relies on continuous Flow emissions with a 1-second delay loop, but this flow stops when the app goes to background.

The race condition:

  1. App goes to background → collectAsStateWithLifecycle() stops collecting
  2. After 5 seconds → SharingStarted.WhileSubscribed(5_000L) stops upstream flows
  3. The delay(ONE_SECOND_MILLISECOND) timer loop stops entirely
  4. On return to foreground:
    • Collection resumes, triggering new subscription
    • Cold Flow is recreated from scratch for each item
    • Stale cached stateIn value briefly displayed before new emissions
    • Multiple items compound this as combine() waits for all flows to emit

Solution

Aligned TotpCodeManagerImpl with the Password Manager's proven pattern:

  1. Return StateFlow instead of cold Flow — Maintains current state, subscribers get immediate value
  2. Per-item StateFlow caching via mutableMapOf<AuthenticatorItem, StateFlow<...>> — Prevents flow recreation on resubscribe
  3. Per-item CoroutineScope — Each TOTP timer runs independently with proper lifecycle
  4. Removed 5-second stop timeout — Uses SharingStarted.WhileSubscribed() without delay
  5. Use SharingStarted.Eagerly for per-item flows — Ensures cached per-item flows continue emitting even when the combined flow changes (e.g., when adding new items)
  6. Explicit cleanup via onCompletion — Removes flows from cache and cancels scope when no longer needed

Changes

  • TotpCodeManager.kt — Changed return type from Flow<List<VerificationCodeItem>> to StateFlow<List<VerificationCodeItem>>
  • TotpCodeManagerImpl.kt — Added DispatcherManager dependency, per-item StateFlow caching with getOrCreateItemStateFlow(), and cleanup handlers
  • AuthenticatorManagerModule.kt — Passes dispatcherManager to TotpCodeManagerImpl constructor
  • AuthenticatorRepositoryImpl.kt — Removed 5-second timeout, now uses SharingStarted.WhileSubscribed() without delay
  • Test files — Updated mocks to use MutableStateFlow() instead of flowOf()

Testing

  • All existing unit tests pass
  • Manual testing verified:
    • Background app for 5+ seconds, return ✓
    • Background during code expiration, return ✓
    • Rotate device while showing codes ✓
    • Rapid app switching ✓

I wasn't able to recreate issue after this fix.

@CLAassistant
Copy link

CLAassistant commented Dec 7, 2025

CLA assistant check
All committers have signed the CLA.

@bitwarden-bot
Copy link

Thank you for your contribution! We've added this to our internal tracking system for review.
ID: PM-29309
Link: https://bitwarden.atlassian.net/browse/PM-29309

Details on our contribution process can be found here: https://contributing.bitwarden.com/contributing/pull-requests/community-pr-process.

@bitwarden-bot bitwarden-bot changed the title [BWA-209] Fix TOTP countdown freeze when returning to Authenticator app (change Flow to StateFlow) [PM-29309] [BWA-209] Fix TOTP countdown freeze when returning to Authenticator app (change Flow to StateFlow) Dec 7, 2025
Refactor TotpCodeManager to use per-item StateFlow caching, matching the
Password Manager's pattern. This prevents flow recreation on resubscribe
and removes the 5-second stop timeout that caused stale state when
returning from background.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Copy link
Contributor

@SaintPatrck SaintPatrck left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for contributing, @tajchert.

I've left a few blocking concerns that need to be addressed.

Since you are using Claude to assist with development, would you be open to allowing our Claude agent to review your changes alongside us?

delay(ONE_SECOND_MILLISECOND)
.stateIn(
scope = itemScope,
started = SharingStarted.Eagerly,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this has the potential to cause leaks. Can we use WhileSubscribed()? That appears to be working in the PasswordManager version.

var verificationCodeItem: VerificationCodeItem? = null

while (currentCoroutineContext().isActive) {
val time = (clock.millis() / ONE_SECOND_MILLISECOND).toInt()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PasswordManager version gets dateTime and time differently. Can you update this to follow it? It will also give you an instance of DateTime so that you don't have to create one inline, below.

Comment on lines +110 to +111
.getOrNull()
?.let { response ->
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use onSuccess and onFailure here, like we're doing in the PasswordManager version?

issueTime = clock.millis(),
id = when (item.source) {
is AuthenticatorItem.Source.Local -> item.source.cipherId
is AuthenticatorItem.Source.Shared -> UUID.randomUUID().toString()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We recently introduced a UuidManager to ease testing. Can you use it here instead of UUID directly?

Comment on lines +141 to +145
// Emit item
emit(verificationCodeItem)

// Wait one second before heading to the top of the loop:
delay(ONE_SECOND_MILLISECOND)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These comments are redundant. The code itself already describes what is being done. Comments would only be helpful here if they described why it's being done, which is obvious.

*
* This implementation uses per-item [StateFlow] caching to prevent flow recreation on each
* subscribe, ensuring smooth UI updates when returning from background. The pattern mirrors
* the Password Manager's [com.x8bit.bitwarden.data.vault.manager.TotpCodeManagerImpl].
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add this to the imports if needed so we don't need the FQN.

@Suppress("LongMethod")
private fun createVerificationCodeFlow(
item: AuthenticatorItem,
) = flow<VerificationCodeItem?> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⛏️

Suggested change
) = flow<VerificationCodeItem?> {
): Flow<VerificationCodeItem?> = flow {


@Test
fun `getTotpCodesFlow should return flow that emits empty list when input list is empty`() =
fun `getTotpCodesFlow should return StateFlow that emits empty list when input list is empty`() =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is going to trigger detekt. Can you add Suppress("MaxLineLength")?

I recommend setting up detekt as a commit-hook as described in README#setup, or at least running it manually before submitting changes.

Comment on lines +15 to +16
* updated verification codes every second. The StateFlow is cached per-item to prevent
* recreation on each subscribe, ensuring smooth UI updates when returning from background.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The last sentence reads more like implementation detail. Since the implementation has good KDoc let's remove those details from the interface.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants