Skip to content

Conversation

@andrebispo5
Copy link
Contributor

@andrebispo5 andrebispo5 commented Oct 31, 2025

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-27150

📔 Objective

This pull request improves how the Authenticator app handles biometric authentication, especially in scenarios where the device's biometric setup changes (e.g., biometrics are disabled or removed). The main focus is on ensuring that the app responds appropriately if biometrics are no longer available, both in navigation logic and in the settings UI.

Biometric Authentication Handling Improvements:

  • The app now checks if biometrics are supported before navigating to the unlock screen; if not, it triggers a new action to clear the biometric key and unlocks the app instead.
  • Added the ClearBiometricsKey action to RootNavAction.Internal, with corresponding handling in RootNavViewModel to clear the key and update navigation state.

Settings Screen Enhancements:

  • The settings screen now rechecks biometric support whenever the app resumes (comes to the foreground), disabling biometric unlock in the app if biometrics are no longer supported on the device.
  • The conditional rendering of the "Unlock with Biometrics" row is now based on a state variable that updates with lifecycle events, ensuring the UI reflects the current device biometric status.

Dependency Injection and Imports:

  • BiometricsManager is now injected into RootNavScreen using LocalBiometricsManager, and necessary imports have been added to support these changes.

📸 Screenshots

Screen.Recording.2025-10-31.at.17.15.20.mov

⏰ Reminders before review

  • Contributor guidelines followed
  • All formatters and local linters executed and passed
  • Written new unit and / or integration tests where applicable
  • Protected functional changes with optionality (feature flags)
  • Used internationalization (i18n) for all UI strings
  • CI builds passed
  • Communicated to DevOps any deployment requirements
  • Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team

🦮 Reviewer guidelines

  • 👍 (:+1:) or similar for great changes
  • 📝 (:memo:) or ℹ️ (:information_source:) for notes or general info
  • ❓ (:question:) for questions
  • 🤔 (:thinking:) or 💭 (:thought_balloon:) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
  • 🎨 (:art:) for suggestions / improvements
  • ❌ (:x:) or ⚠️ (:warning:) for more significant problems or concerns needing attention
  • 🌱 (:seedling:) or ♻️ (:recycle:) for future improvements or indications of technical debt
  • ⛏ (:pick:) for minor or nitpick changes

@andrebispo5 andrebispo5 changed the title [PM-27150] React to device changes on device screen unlock method. [PM-27150] React to device changes on device screen unlock method Oct 31, 2025
@claude
Copy link
Contributor

claude bot commented Oct 31, 2025

Claude finished @andrebispo5's task —— View job


Code Review Complete

Overall Assessment: APPROVE

This PR successfully implements biometric authentication handling improvements for the Authenticator app. The implementation properly reacts to device biometric changes, follows MVVM architecture, and includes comprehensive test coverage.

Summary

The changes introduce reactive biometric state monitoring that:

  • Detects when device biometrics are disabled/removed
  • Automatically clears biometric keys and navigates appropriately
  • Updates Settings UI to reflect current device capabilities
  • Maintains proper separation of concerns between ViewModels

Key Strengths

Architecture compliance: Follows MVVM patterns with proper state hoisting to ViewModels
Comprehensive testing: 683 new test lines cover all major scenarios including edge cases
Reactive data layer: Proper use of SharedFlow with replay buffer for state propagation
Clean separation: BiometricChanges composable is well-structured, documented, and reusable
Previous feedback addressed: All review comments resolved, including assertassertTrue/assertFalse
Consistent patterns: Same architectural approach used in both RootNav and Settings screens

Minor Considerations

I've added inline comments with:

  • 💭 Two questions about design trade-offs (LaunchedEffect necessity, initialization strategy)
  • 🎨 One minor readability suggestion (extract navigation state check)
  • 👍 Four positive observations highlighting good practices

These are all minor observations and don't block approval. The current implementation is solid and production-ready.


@codecov
Copy link

codecov bot commented Oct 31, 2025

Codecov Report

❌ Patch coverage is 87.27273% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.22%. Comparing base (9baec6e) to head (3c01f60).
⚠️ Report is 28 commits behind head on main.

Files with missing lines Patch % Lines
...or/data/auth/datasource/disk/AuthDiskSourceImpl.kt 20.00% 4 Missing ⚠️
...cator/ui/platform/feature/rootnav/RootNavScreen.kt 50.00% 2 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6103      +/-   ##
==========================================
+ Coverage   84.97%   85.22%   +0.25%     
==========================================
  Files         723      723              
  Lines       52746    52901     +155     
  Branches     7649     7683      +34     
==========================================
+ Hits        44822    45087     +265     
+ Misses       5250     5126     -124     
- Partials     2674     2688      +14     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions
Copy link
Contributor

github-actions bot commented Oct 31, 2025

Logo
Checkmarx One – Scan Summary & Details58956112-a058-4f40-85ee-abb8df978363

Great job! No new security vulnerabilities introduced in this pull request

navController.navigateToTutorial(rootNavOptions)
}

RootNavState.NavState.Locked -> {
Copy link
Collaborator

Choose a reason for hiding this comment

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

This should not be happening at this level. Can we move this logic into the UnlockScreen?

private fun handleClearBiometricsKey() {
settingsRepository.clearBiometricsKey()
mutableStateFlow.update {
it.copy(navState = RootNavState.NavState.Unlocked)
Copy link
Collaborator

Choose a reason for hiding this comment

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

This should not be needed. Just observe the isUnlockWithBiometricsEnabled state.


// Recheck biometrics support when app comes to foreground
LifecycleEventEffect { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we make this a composable that is called from the top level of the screen.

Additionally, can we send the state to the VM to handle this logic.

@Composable
private fun BiometricChanges(
    onBiometricSupportChange: (isSupported: Boolean) -> Unit,
) {
    LifecycleEventEffect { _, event ->
        when (event) {
            Lifecycle.Event.ON_RESUME -> {
                onBiometricSupportChange(biometricsManager.isBiometricsSupported)
            }
            else -> Unit
        }
    }
}

),
AuthDiskSource {
private val mutableUserBiometricUnlockKeyFlow =
bufferedMutableSharedFlow<String?>()
Copy link
Collaborator

Choose a reason for hiding this comment

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

Shouldn't this have a relay of 1?

override fun getUserBiometricUnlockKey(): String? =
getEncryptedString(key = BIOMETRICS_UNLOCK_KEY)

override fun getUserBiometricUnlockKeyFlow(): Flow<String?> = mutableUserBiometricUnlockKeyFlow
Copy link
Collaborator

@david-livefront david-livefront Nov 6, 2025

Choose a reason for hiding this comment

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

Can we make this a val with a get() here?

get() =
authDiskSource
.getUserBiometricUnlockKeyFlow()
.map { it != null }
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can you run the foirmatter here, this looks overly indented. You can also move the authDiskSource up a line


private var lastActiveTimeMillis: Long? = null
private var userBiometricUnlockKey: String? = null
private val userBiometricUnlockKeyFlow = bufferedMutableSharedFlow<String?>()
Copy link
Collaborator

Choose a reason for hiding this comment

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

Same thing here, shouldn't this have a replay of 1?

) {
mutableStateFlow.update {
it.copy(
isUnlockWithBiometricsEnabled = action.isEnabled,
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍

BiometricChanges(
biometricsManager = biometricsManager,
onBiometricSupportChange = {
viewModel.trySendAction(RootNavAction.Internal.BiometricSupportChanged(it))
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is an internal event but you are sending it externally?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nice catch, I was moving things around and didn't noticed. ty

}

is RootNavAction.Internal.BiometricSupportChanged -> {
handleBiometricSupportChanged(action.isBiometricsSupported)
Copy link
Collaborator

Choose a reason for hiding this comment

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

You should be passing the entire action through

# Conflicts:
#	authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt
/**
* Indicates an update on device biometrics support.
*/
data class BiometricSupportChanged(val isBiometricsSupported: Boolean) : Internal()
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Finding 1: BiometricSupportChanged should be a root-level action

Details

The BiometricSupportChanged action is currently nested under Internal but is sent externally from RootNavScreen:

// In RootNavScreen.kt:72
viewModel.trySendAction(RootNavAction.BiometricSupportChanged(it))

Issue: Actions marked as Internal should only be sent by the ViewModel itself, not from external UI components.

Recommendation: Move BiometricSupportChanged outside the Internal sealed class to be a direct subclass of RootNavAction, similar to BackStackUpdate:

sealed class RootNavAction {
    data object BackStackUpdate : RootNavAction()
    
    data class BiometricSupportChanged(val isBiometricsSupported: Boolean) : RootNavAction()
    
    sealed class Internal : RootNavAction() {
        // ... existing internal actions
    }
}

This follows the established pattern where external UI events are root-level actions while async/internal events are nested under Internal.

sharedPreferences = sharedPreferences,
),
AuthDiskSource {
private val mutableUserBiometricUnlockKeyFlow = bufferedMutableSharedFlow<String?>(replay = 1)
Copy link
Contributor

Choose a reason for hiding this comment

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

🎨 Finding 2: Consider adding replay buffer to MutableSharedFlow

Details

The mutableUserBiometricUnlockKeyFlow uses replay = 1 which ensures new collectors receive the most recent value. However, this should be verified against the app's password manager module for consistency.

Looking at the password manager module in app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt, similar flows use replay = 1 for state that should be immediately available to new subscribers.

Current implementation is correct - the replay = 1 parameter ensures that:

  1. New subscribers immediately receive the current biometric key state
  2. The flow behaves consistently with other state flows in the codebase
  3. The isUnlockWithBiometricsEnabledFlow properly reflects the current state

This is the appropriate pattern for this use case.

.onEach(::sendAction)
.launchIn(viewModelScope)
settingsRepository
.isUnlockWithBiometricsEnabledFlow
Copy link
Contributor

Choose a reason for hiding this comment

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

♻️ Finding 3: New Flow observation creates potential for state inconsistency

Details

The ViewModel now observes isUnlockWithBiometricsEnabledFlow to update state, but this creates a dual path for state updates:

  1. Direct path: User toggles biometrics → handleBiometricsSetupClick → updates state directly
  2. Flow path: Disk source changes → Flow emits → handleUnlockWithBiometricsUpdated → updates state

Concern: When RootNavViewModel clears the biometric key (when biometrics become unsupported), the flow will emit and update SettingsViewModel's state. However, this happens asynchronously and could race with user interactions.

Current implementation appears safe because:

  • The biometric toggle in settings already checks biometricsManager.isBiometricsSupported before showing (SettingsScreen.kt:302)
  • The repository clears the key immediately, and the flow propagates the change
  • State updates are synchronized through the action queue

However, this architectural pattern is more complex than necessary. Consider whether the Settings screen needs this flow observation or if it could rely on the repository's synchronous isUnlockWithBiometricsEnabled property.

Comment on lines 68 to 73
BiometricChanges(
biometricsManager = biometricsManager,
onBiometricSupportChange = {
viewModel.trySendAction(RootNavAction.BiometricSupportChanged(it))
},
)
Copy link
Contributor

Choose a reason for hiding this comment

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

🎨 Add remember(viewModel) for callback stability

Details

Per david-livefront's comment: "Can you make sure to remember the viewModel here"

Current code:

onBiometricSupportChange = {
    viewModel.trySendAction(RootNavAction.BiometricSupportChanged(it))
},

Recommended:

onBiometricSupportChange = remember(viewModel) {
    { viewModel.trySendAction(RootNavAction.BiometricSupportChanged(it)) }
},

Why this matters:

  • While the lambda is already stable (captures viewModel from hiltViewModel()), explicit remember makes the intent clearer
  • Follows Compose best practices for callback stability
  • Prevents potential unnecessary recompositions of BiometricChanges
  • Consistent with pattern used elsewhere in the file (lines 166-172)

biometricsManager = biometricsManager,
onBiometricSupportChange = {
viewModel.trySendAction(RootNavAction.BiometricSupportChanged(it))
},
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Finding 1: Callback creates unnecessary lambda allocation on each recomposition

Details

Per david-livefront's comment, the callback should be wrapped in remember(viewModel) for stability:

onBiometricSupportChange = remember(viewModel) {
    { viewModel.trySendAction(RootNavAction.BiometricSupportChanged(it)) }
},

This prevents unnecessary recompositions of BiometricChanges and follows the pattern established elsewhere in this file (lines 166-172).

showSyncWithBitwarden = shouldShowSyncWithBitwarden,
showDefaultSaveOptionRow = shouldShowDefaultSaveOption,
allowScreenCapture = isScreenCaptureAllowed,
hasBiometricsSupport = true,
Copy link
Contributor

Choose a reason for hiding this comment

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

💭 Finding 2: Hardcoded initialization may not reflect actual device capability

Details

Initializing hasBiometricsSupport = true assumes all devices support biometrics. While BiometricChanges updates this on first app resume, there's a brief window where the UI may incorrectly show the biometric toggle.

Current behavior:

  • First launch: Toggle appears (even on devices without biometrics)
  • After resume: Toggle correctly hidden if unsupported

Considerations:

  1. This approach optimizes for the common case (devices with biometrics)
  2. The delay is minimal (only until first ON_RESUME)
  3. Alternative (init to false) would hide the toggle even on supported devices until first resume

Question: Is this intentional, or should we initialize to false to be more conservative?

biometricsManager: BiometricsManager,
onBiometricSupportChange: (isSupported: Boolean) -> Unit,
) {
LaunchedEffect(biometricsManager.isBiometricsSupported) {
Copy link
Contributor

Choose a reason for hiding this comment

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

👍 Well-structured implementation

Details

The combination of LaunchedEffect + LifecycleEventEffect provides comprehensive biometric change detection:

  • LaunchedEffect: Detects changes during recomposition (when isBiometricsSupported changes)
  • LifecycleEventEffect: Detects changes when app returns from background

This dual approach, as suggested by david-livefront, ensures biometric support status is checked in all relevant scenarios.

}
}

private fun handleBiometricSupportChanged(
Copy link
Contributor

Choose a reason for hiding this comment

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

🎨 Good navigation logic centralization

Details

The handleBiometricSupportChanged function correctly:

✓ Clears the biometric key when biometrics become unsupported
✓ Navigates from Locked → Unlocked state when currently locked
✓ Centralizes navigation decisions in the ViewModel per MVVM architecture

This ensures the app gracefully handles biometric removal while maintaining a single source of truth for navigation state.

Note: Test coverage for this function was added in RootNavViewModelTest.kt, addressing the codecov gap.

showSyncWithBitwarden = shouldShowSyncWithBitwarden,
showDefaultSaveOptionRow = shouldShowDefaultSaveOption,
allowScreenCapture = isScreenCaptureAllowed,
hasBiometricsSupport = true,
Copy link
Contributor

Choose a reason for hiding this comment

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

💭 Finding 1: Hardcoded biometric support initialization

Details

The hasBiometricsSupport field is hardcoded to true during initialization:

hasBiometricsSupport = true,

Impact: On devices without biometric hardware, the toggle will briefly appear until BiometricChanges triggers on first ON_RESUME.

Consideration: This optimizes for the common case (devices with biometrics) but creates a brief UX inconsistency on devices without biometric support.

Alternative approaches:

  1. Initialize to false (more conservative, but hides toggle even on supported devices until first resume)
  2. Accept current behavior as a reasonable tradeoff given that most devices support biometrics

Question: Is this intentional behavior? If so, consider adding a comment explaining the tradeoff.

biometricsManager: BiometricsManager,
onBiometricSupportChange: (isSupported: Boolean) -> Unit,
) {
LaunchedEffect(biometricsManager.isBiometricsSupported) {
Copy link
Contributor

Choose a reason for hiding this comment

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

👍 Finding 2: Comprehensive biometric monitoring implementation

Details

The combination of LaunchedEffect + LifecycleEventEffect provides thorough biometric change detection:

  • LaunchedEffect: Detects changes during recomposition when isBiometricsSupported changes
  • LifecycleEventEffect: Detects changes when app returns from background

This dual approach ensures biometric support status is checked in all relevant scenarios, addressing david-livefront's suggestion from the review.

Well-structured aspects:
✓ Comprehensive KDoc documentation
✓ Clear separation of concerns
✓ Proper use of lifecycle-aware effects
✓ Reusable composable design

}
}

private fun handleBiometricSupportChanged(
Copy link
Contributor

Choose a reason for hiding this comment

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

👍 Finding 3: Proper navigation logic centralization

Details

The handleBiometricSupportChanged function correctly implements MVVM architecture:

✓ Clears the biometric key when biometrics become unsupported
✓ Navigates from Locked → Unlocked state when currently locked
✓ Centralizes navigation decisions in the ViewModel
✓ Has comprehensive test coverage in RootNavViewModelTest.kt

This ensures the app gracefully handles biometric removal while maintaining a single source of truth for navigation state, which is exactly what was requested in earlier review feedback.

Navigation flow when biometrics are disabled:

  1. BiometricChanges detects change on ON_RESUME
  2. RootNavViewModel receives BiometricSupportChanged(false)
  3. Biometric key cleared via settingsRepository.clearBiometricsKey()
  4. If locked, immediately navigates to unlocked state
  5. Flow propagates to SettingsViewModel to update UI


BiometricChanges(
biometricsManager = biometricsManager,
onBiometricSupportChange = remember(viewModel) {
Copy link
Contributor

Choose a reason for hiding this comment

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

👍 Finding 4: Proper callback memoization

Details

The callback is correctly wrapped with remember(viewModel) for stability:

onBiometricSupportChange = remember(viewModel) {
    { viewModel.trySendAction(RootNavAction.BiometricSupportChanged(it)) }
},

This prevents unnecessary recompositions of BiometricChanges and follows Compose best practices, addressing david-livefront's feedback from the review.

.onEach(::sendAction)
.launchIn(viewModelScope)
settingsRepository
.isUnlockWithBiometricsEnabledFlow
Copy link
Contributor

Choose a reason for hiding this comment

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

👍 Finding 5: Appropriate flow observation pattern

Details

The ViewModel observes isUnlockWithBiometricsEnabledFlow to reactively update state when the biometric key is cleared externally. This creates a proper reactive architecture:

Update paths:

  • Direct path: User toggles → handleBiometricsSetupClick → updates state
  • Flow path: External clear (from RootNavViewModel) → Disk source emits → Flow observer → updates state

Why this is correct:

  • The biometric key can be cleared from multiple locations (RootNavViewModel when biometrics become unsupported, SettingsViewModel when user disables)
  • Flow observation ensures Settings UI stays in sync with external changes
  • Maintains consistency across the app without tight coupling

This pattern follows the repository pattern correctly and ensures single source of truth from the data layer.

sharedPreferences = sharedPreferences,
),
AuthDiskSource {
private val mutableUserBiometricUnlockKeyFlow = bufferedMutableSharedFlow<String?>(replay = 1)
Copy link
Contributor

Choose a reason for hiding this comment

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

👍 Finding 6: Correct use of replay buffer

Details

The mutableUserBiometricUnlockKeyFlow correctly uses replay = 1:

private val mutableUserBiometricUnlockKeyFlow = bufferedMutableSharedFlow<String?>(replay = 1)

Why this is correct:

  • State availability: New subscribers immediately receive the most recent biometric key value
  • Consistent with app module: Matches the pattern in the password manager's AuthDiskSourceImpl
  • Proper state semantics: The biometric key is stateful data that should be immediately available to observers

Flow behavior:

  1. onSubscription emits current value from disk
  2. replay = 1 ensures late subscribers get the most recent emission
  3. tryEmit in storeUserBiometricUnlockKey broadcasts changes to all active collectors

This implementation correctly maintains a reactive, state-like flow for biometric key changes.

navOptions = expectedNavOptions,
)
}
assert(!onSplashScreenRemovedCalled)
Copy link
Collaborator

Choose a reason for hiding this comment

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

These should use assertTrue or assertFalse we never use the regular assert

onBiometricSupportChange: (isSupported: Boolean) -> Unit,
) {
LaunchedEffect(biometricsManager.isBiometricsSupported) {
onBiometricSupportChange(biometricsManager.isBiometricsSupported)
Copy link
Contributor

Choose a reason for hiding this comment

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

💭 LaunchedEffect may cause unnecessary emissions

Details

The LaunchedEffect(biometricsManager.isBiometricsSupported) will re-trigger whenever isBiometricsSupported changes. However, BiometricsManager.isBiometricsSupported is typically a property that reads system state synchronously, not a state variable that changes.

Current behavior:

  • LaunchedEffect fires on initial composition
  • LifecycleEventEffect fires on ON_RESUME
  • Both may fire the callback with the same value

Question: Does isBiometricsSupported change during recomposition without a lifecycle event? If not, the LaunchedEffect may be redundant since LifecycleEventEffect on ON_RESUME already handles the primary use case (detecting changes when app returns from Settings).

If keeping both: Consider whether the initial LaunchedEffect emission on first composition is necessary, or if you want to wait for the first ON_RESUME to check biometric support.

showSyncWithBitwarden = shouldShowSyncWithBitwarden,
showDefaultSaveOptionRow = shouldShowDefaultSaveOption,
allowScreenCapture = isScreenCaptureAllowed,
hasBiometricsSupport = true,
Copy link
Contributor

Choose a reason for hiding this comment

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

💭 Consider the initialization strategy for hasBiometricsSupport

Details

The field is hardcoded to true during initialization:

hasBiometricsSupport = true,

Current behavior:

  • On first launch, biometric toggle shows even on devices without biometric hardware
  • BiometricChanges updates this to correct value on first ON_RESUME
  • Brief window where UI may incorrectly show the toggle

Trade-offs:

  1. Current approach (init to true): Optimizes for common case (devices with biometrics), but creates brief UX inconsistency on devices without biometric support

  2. Alternative (init to false): More conservative, hides toggle initially even on supported devices until first resume validates support

Recommendation: Consider adding a comment explaining this design choice, or evaluate whether initializing to false would be a better default to avoid showing UI that shouldn't be there.

// Initialize to true as most devices support biometrics.
// BiometricChanges will update to false on first resume if unsupported.
hasBiometricsSupport = true,

action: RootNavAction.BiometricSupportChanged,
) {
if (!action.isBiometricsSupported) {
settingsRepository.clearBiometricsKey()
Copy link
Contributor

Choose a reason for hiding this comment

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

🎨 Consider extracting navigation state check to improve readability

Details

The current implementation checks the navigation state inline:

if (mutableStateFlow.value.navState is RootNavState.NavState.Locked) {
    mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Unlocked) }
}

Minor improvement: Extract to a local variable for clarity:

private fun handleBiometricSupportChanged(
    action: RootNavAction.BiometricSupportChanged,
) {
    if (!action.isBiometricsSupported) {
        settingsRepository.clearBiometricsKey()

        // If currently locked, navigate to unlocked since biometrics are no longer available
        val isCurrentlyLocked = mutableStateFlow.value.navState is RootNavState.NavState.Locked
        if (isCurrentlyLocked) {
            mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Unlocked) }
        }
    }
}

This makes the intent clearer and slightly improves readability, though the current implementation is acceptable.

sharedPreferences = sharedPreferences,
),
AuthDiskSource {
private val mutableUserBiometricUnlockKeyFlow = bufferedMutableSharedFlow<String?>(replay = 1)
Copy link
Contributor

Choose a reason for hiding this comment

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

👍 Correct use of replay buffer

Details

The replay = 1 parameter is correctly used here:

private val mutableUserBiometricUnlockKeyFlow = bufferedMutableSharedFlow<String?>(replay = 1)

Why this is correct:

  • New subscribers immediately receive the most recent biometric key value
  • Matches the pattern in the password manager's AuthDiskSourceImpl
  • Proper state semantics for stateful data that should be immediately available

The onSubscription emission combined with replay = 1 ensures:

  1. New collectors get the current value from disk immediately
  2. Late subscribers also receive the most recent emission
  3. Changes propagate to all active collectors via tryEmit

This correctly maintains a reactive, state-like flow for biometric key changes.


BiometricChanges(
biometricsManager = biometricsManager,
onBiometricSupportChange = remember(viewModel) {
Copy link
Contributor

Choose a reason for hiding this comment

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

👍 Proper callback memoization

Details

The callback is correctly wrapped with remember(viewModel):

onBiometricSupportChange = remember(viewModel) {
    {
        viewModel.trySendAction(RootNavAction.BiometricSupportChanged(it))
    }
},

This prevents unnecessary recompositions of BiometricChanges and follows Compose best practices for callback stability. The remember(viewModel) key ensures the lambda is only recreated if the ViewModel instance changes, which shouldn't happen during the lifecycle of this screen.

}

@Test
fun `on BiometricSupportChanged with false should clear biometrics key`() {
Copy link
Contributor

Choose a reason for hiding this comment

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

👍 Comprehensive test coverage for biometric changes

Details

Excellent test coverage for the BiometricSupportChanged action:

✓ Tests clearing key when biometrics become unsupported
✓ Tests NOT clearing key when biometrics remain supported
✓ Tests navigation from Locked → Unlocked when biometrics disabled
✓ Tests no navigation change when already Unlocked

These tests properly validate all code paths in handleBiometricSupportChanged and cover both the repository interaction and the navigation state logic. This addresses the test coverage gaps that were identified in earlier reviews.


BiometricChanges(
biometricsManager = biometricsManager,
onBiometricSupportChange = remember(viewModel) {
Copy link
Contributor

Choose a reason for hiding this comment

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

👍 Clean integration of BiometricChanges composable

Details

The BiometricChanges composable is properly integrated into the Settings screen:

✓ Uses remember(viewModel) for callback stability
✓ Sends actions to ViewModel following MVVM architecture
✓ Placed at the top level of the screen composable

This ensures biometric support changes are monitored whenever the Settings screen is active, and the ViewModel maintains the single source of truth for the biometric support state.

The same pattern is correctly used in RootNavScreen, showing good architectural consistency across the codebase.

@andrebispo5 andrebispo5 added this pull request to the merge queue Nov 18, 2025
Merged via the queue into main with commit d81b000 Nov 18, 2025
46 of 61 checks passed
@andrebispo5 andrebispo5 deleted the pm-27150/authenticator-unlock-enabled-bug branch November 18, 2025 16:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants